Skip to content

perf: replace str_replace with substr + cache in getPermissionsByType#858

Open
loks0n wants to merge 1 commit intomainfrom
perf/permissions
Open

perf: replace str_replace with substr + cache in getPermissionsByType#858
loks0n wants to merge 1 commit intomainfrom
perf/permissions

Conversation

@loks0n
Copy link
Copy Markdown
Contributor

@loks0n loks0n commented Apr 17, 2026

Summary

  • Replaces str_replace with str_starts_with + substr in getPermissionsByType(), exploiting the predictable type("role") format to use a single C-level substr call instead of multiple string replacements
  • Adds a per-instance $permissionCache so repeated calls for the same type on the same document (e.g. getRead() called multiple times) return immediately
  • Cache is cleared in setAttribute() when $permissions is mutated to prevent stale reads

Profiling showed ~294K str_replace calls attributable to this method. This patch eliminates them entirely.

Test plan

  • Existing permission-related unit tests pass
  • Verify getRead(), getCreate(), getUpdate(), getDelete(), getWrite() return correct roles
  • Verify cache invalidates correctly after setAttribute('$permissions', ...)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor
    • Optimized permission retrieval logic for improved application responsiveness.

Eliminates repeated str_replace calls (294K+ per profiling run) by using
str_starts_with + substr on the predictable type("role") format, and caches
results per Document instance. Cache is invalidated on setAttribute('$permissions').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

The Document class now implements permission caching to optimize repeated permission lookups by type. A private $permissionCache property stores computed permission subsets, and the setAttribute() method clears this cache when permissions are updated.

Changes

Cohort / File(s) Summary
Permission Caching Implementation
src/Database/Document.php
Added memoization of permission subsets in getPermissionsByType() via a new $permissionCache property. Refactored permission matching logic to use prefix-based extraction. Updated setAttribute() to invalidate the cache when permissions change.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops through cached requests with glee,
No more recomputing what was meant to be! 🐰
Permissions stored, efficiency gained,
Each type remembered, perfectly maintained,
Cache invalidated with every change so clear,
Swift lookups await, optimization is here! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: replacing str_replace with substr and adding caching to the getPermissionsByType() method, which is the core optimization in this performance-focused PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/permissions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 17, 2026

Greptile Summary

This PR optimises getPermissionsByType() by replacing the multi-string str_replace with a single substr extraction (exploiting the fixed type("role") format) and adds a per-instance permissionCache to short-circuit repeated calls. The substr($permission, $prefixLen, -2) extraction is correct for all well-formed permission strings, and the cache is properly cleared in setAttribute.

  • P1 – stale cache via direct array access: Document extends ArrayObject, so $doc['$permissions'] = [...] is a valid, documented-by-example pattern (used throughout PermissionsTest.php). This path never calls setAttribute, so permissionCache is not cleared. A subsequent call to getRead() / getCreate() etc. would return the pre-mutation results. Overriding offsetSet and offsetUnset in Document to mirror the setAttribute guard would close this gap.

Confidence Score: 4/5

Safe to merge if the offsetSet/offsetUnset invalidation gap is addressed; the core substr optimisation is correct.

One P1 finding: the cache can go stale when $permissions is mutated through ArrayObject's direct offset interface, which is an established usage pattern in the test suite. No data-loss or security risk, but incorrect permission reads are possible. All other aspects of the change are correct.

src/Database/Document.php — cache invalidation coverage in offsetSet/offsetUnset

Important Files Changed

Filename Overview
src/Database/Document.php Adds per-instance permissionCache with substr-based extraction; cache is cleared in setAttribute but not when $permissions is mutated via the ArrayObject direct-offset interface ($doc['$permissions'] = [...]), creating a stale-cache path.

Reviews (1): Last reviewed commit: "perf: replace str_replace with substr + ..." | Re-trigger Greptile

Comment thread src/Database/Document.php
Comment on lines +150 to +164
if (isset($this->permissionCache[$type])) {
return $this->permissionCache[$type];
}

$prefix = $type . '("';
$prefixLen = \strlen($prefix);
$result = [];

foreach ($this->getPermissions() as $permission) {
if (!\str_starts_with($permission, $type)) {
continue;
if (\str_starts_with($permission, $prefix)) {
$result[] = \substr($permission, $prefixLen, -2);
}
$typePermissions[] = \str_replace([$type . '(', ')', '"', ' '], '', $permission);
}

return \array_unique($typePermissions);
return $this->permissionCache[$type] = \array_unique($result);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale cache when $permissions is mutated via direct array access

Document extends ArrayObject, so callers can bypass setAttribute entirely with $doc['$permissions'] = [...]. The cache is only invalidated in setAttribute, so any direct offset write leaves permissionCache holding the old results. The test file PermissionsTest.php already demonstrates this pattern (e.g. lines 37–48), repeatedly assigning $document['$permissions'] = [...] on a live Document instance — if getRead() were called before and after one of those assignments, it would return stale data.

To fully guard against this, override offsetSet and offsetUnset in Document:

public function offsetSet(mixed $key, mixed $value): void
{
    if ($key === '$permissions') {
        $this->permissionCache = [];
    }
    parent::offsetSet($key, $value);
}

public function offsetUnset(mixed $key): void
{
    if ($key === '$permissions') {
        $this->permissionCache = [];
    }
    parent::offsetUnset($key);
}

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/Database/Document.php (1)

156-164: Redundant array_unique on the result.

getPermissions() (line 98) already returns array_values(array_unique(...)), and since every matched entry here shares the same fixed prefix ($type(") and suffix (")), duplicate inner values would require duplicate full permission strings — which have already been deduplicated. The trailing \array_unique($result) can be dropped.

♻️ Proposed simplification
-        return $this->permissionCache[$type] = \array_unique($result);
+        return $this->permissionCache[$type] = $result;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Document.php` around lines 156 - 164, The final
\array_unique($result) is redundant because getPermissions() already returns
deduplicated permission strings; update Document:: (the block that iterates over
$this->getPermissions() and builds $result) to simply assign the
unique-permission-derived array to $this->permissionCache[$type] without calling
\array_unique(), i.e. remove the trailing \array_unique call and return the
$result after building it (still keep \substr and prefix/suffix logic).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Document.php`:
- Around line 252-257: Document's permissionCache can become stale because
ArrayObject's ArrayAccess methods can mutate '$permissions' without going
through setAttribute; override offsetSet() and offsetUnset() in the Document
class to clear $this->permissionCache when the mutated key is '$permissions'
(then call parent::offsetSet/offsetUnset to preserve existing behavior). Ensure
the overrides handle string keys (and null keys for append) and mirror any
existing side-effects of setAttribute so
getRead()/getCreate()/getUpdate()/getDelete()/getPermissionsByType() see the
cleared cache.

---

Nitpick comments:
In `@src/Database/Document.php`:
- Around line 156-164: The final \array_unique($result) is redundant because
getPermissions() already returns deduplicated permission strings; update
Document:: (the block that iterates over $this->getPermissions() and builds
$result) to simply assign the unique-permission-derived array to
$this->permissionCache[$type] without calling \array_unique(), i.e. remove the
trailing \array_unique call and return the $result after building it (still keep
\substr and prefix/suffix logic).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9a984586-5488-4fce-b044-8db246d5ba28

📥 Commits

Reviewing files that changed from the base of the PR and between ee2d7d4 and 89efb49.

📒 Files selected for processing (1)
  • src/Database/Document.php

Comment thread src/Database/Document.php
Comment on lines 252 to +257
public function setAttribute(string $key, mixed $value, string $type = self::SET_TYPE_ASSIGN): static
{
if ($key === '$permissions') {
$this->permissionCache = [];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find direct ArrayAccess writes to $permissions on Document instances.
rg -nP --type=php -C2 '\[[\x27"]\$permissions[\x27"]\]\s*(\[\]\s*)?=' 
rg -nP --type=php -C2 '->offsetSet\(\s*[\x27"]\$permissions[\x27"]'

Repository: utopia-php/database

Length of output: 12782


🏁 Script executed:

# Check Document class structure
cat -n src/Database/Document.php | head -30

Repository: utopia-php/database

Length of output: 953


🏁 Script executed:

# Check if Document extends ArrayObject and if offsetSet is overridden
rg -A5 'class Document' src/Database/Document.php | head -20

Repository: utopia-php/database

Length of output: 237


🏁 Script executed:

# Check for offsetSet/offsetUnset methods in Document
rg 'offsetSet|offsetUnset' src/Database/Document.php

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check what happens in SQL.php after the ArrayAccess mutation on line 424
sed -n '420,435p' src/Database/Adapter/SQL.php

Repository: utopia-php/database

Length of output: 603


🏁 Script executed:

# Check context around SQL.php line 3213
sed -n '3209,3225p' src/Database/Adapter/SQL.php

Repository: utopia-php/database

Length of output: 672


🏁 Script executed:

# Verify if getPermissions is called right after ArrayAccess mutations in realistic scenarios
rg -B3 -A3 "getPermissions\(\)" src/Database/Adapter/SQL.php | head -50

Repository: utopia-php/database

Length of output: 1901


🏁 Script executed:

# Check Document constructor to see if/how permissionCache is initialized
sed -n '30,80p' src/Database/Document.php

Repository: utopia-php/database

Length of output: 1449


🏁 Script executed:

# Check getPermissions and getPermissionsByType implementation
sed -n '148,170p' src/Database/Document.php

Repository: utopia-php/database

Length of output: 710


🏁 Script executed:

# Look for the full getPermissions method
rg -A10 'public function getPermissions' src/Database/Document.php

Repository: utopia-php/database

Length of output: 701


🏁 Script executed:

# Check what test does after ArrayAccess mutations
sed -n '35,50p' tests/unit/Validator/PermissionsTest.php

Repository: utopia-php/database

Length of output: 1176


🏁 Script executed:

# Check if tests call getRead/getCreate/getUpdate/getDelete (which use cached getPermissionsByType)
rg 'getRead|getCreate|getUpdate|getDelete' tests/unit/Validator/PermissionsTest.php

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Check if actual production code in Database.php uses these methods on Documents after mutations
rg -B5 -A5 'getRead\(\)|getCreate\(\)|getUpdate\(\)|getDelete\(\)' src/Database/Database.php | head -60

Repository: utopia-php/database

Length of output: 2185


🏁 Script executed:

# Check if Document instances have offsetSet behavior issues in actual usage
# Look for patterns where ArrayAccess mutation happens then getRead/getCreate/getUpdate/getDelete called
rg -B10 -A10 '\[\s*[\x27"]\$permissions[\x27"]\s*\].*=' tests/ --type=php | grep -A10 'getRead\|getCreate\|getUpdate\|getDelete' | head -40

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Double-check: does cache initialization in constructor populate cache?
grep -A30 'public function __construct' src/Database/Document.php | grep -i cache

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Verify the actual cache behavior - when is permissionCache populated?
rg 'permissionCache\[' src/Database/Document.php

Repository: utopia-php/database

Length of output: 235


Document's permission cache can become stale if $permissions is modified via ArrayAccess after getRead()/getCreate()/getUpdate()/getDelete() are called.

Document extends ArrayObject without overriding offsetSet() or offsetUnset(), so callers can bypass setAttribute() — e.g. $doc['$permissions'] = [...]. Since getPermissionsByType() caches results by type, any ArrayAccess mutation after a call to one of the typed permission getters will leave the cache stale.

This pattern does not currently occur in the codebase (tests that mutate via ArrayAccess only call getPermissions(), which doesn't cache; production code loads documents from the database or uses setAttribute()). However, to prevent future bugs, consider overriding offsetSet()/offsetUnset() to clear the cache, or document that direct ArrayAccess mutations to $permissions are unsupported.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Document.php` around lines 252 - 257, Document's permissionCache
can become stale because ArrayObject's ArrayAccess methods can mutate
'$permissions' without going through setAttribute; override offsetSet() and
offsetUnset() in the Document class to clear $this->permissionCache when the
mutated key is '$permissions' (then call parent::offsetSet/offsetUnset to
preserve existing behavior). Ensure the overrides handle string keys (and null
keys for append) and mirror any existing side-effects of setAttribute so
getRead()/getCreate()/getUpdate()/getDelete()/getPermissionsByType() see the
cleared cache.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant