From b2cb5ec0d53131e06e958848c8d6186ab5589cbe Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 5 Oct 2026 12:08:49 +0530 Subject: [PATCH 1/7] fix(postgres): check all roles with one indexable ?| condition Each role was its own @> check OR'ed together. The planner's estimate grows with every role, and around 100 roles it abandons the GIN index for a primary key walk. Match all roles with one ?| through an inlined SQL function, so the SQL text has no ? for PDO to read as a placeholder. The function is created with the schema, or on first use for existing databases; where it can't be created, the per-role checks are kept. --- src/Database/Adapter/Postgres.php | 97 ++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 3004e9780..4cc594de2 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -32,6 +32,18 @@ class Postgres extends SQL { public const MAX_IDENTIFIER_NAME = 63; + /** + * Function that tests a row's permissions against a set of roles with jsonb's ?| operator. + */ + protected const PERMISSIONS_FUNCTION = '_permissions_any'; + + /** + * Schemas known to have the permissions function, per connection. + * + * @var \WeakMap>|null + */ + protected static ?\WeakMap $permissionsFunctions = null; + /** * @inheritDoc */ @@ -141,6 +153,9 @@ public function create(string $name): bool ) "; $this->getPDO()->prepare($collation)->execute(); + + $this->ensurePermissionsFunction($name); + return $dbCreation; } @@ -159,6 +174,10 @@ public function delete(string $name): bool $sql = "DROP SCHEMA IF EXISTS \"{$name}\" CASCADE"; $sql = $this->trigger(Database::EVENT_DATABASE_DELETE, $sql); + if (self::$permissionsFunctions !== null) { + unset(self::$permissionsFunctions[$this->getPDO()]); + } + return $this->getPDO()->prepare($sql)->execute(); } @@ -1831,22 +1850,86 @@ protected function getSQLPermissionsCondition( throw new DatabaseException('Unknown permission type: ' . $type); } + if ($roles === []) { + return 'FALSE'; + } + $column = "{$this->quote($alias)}.{$this->quote('_permissions')}"; - // Containment rather than jsonb's ?| key operator: PDO reads a lone ? as a positional - // placeholder, and doubling it to escape breaks once a named placeholder is repeated, - // which the cursor conditions do. Each role is its own @> so the index can answer them - // as a BitmapOr; jsonb_exists_any would express it in one call but is not indexable. + // One ?| for all roles keeps the estimate flat however many roles there are; a @> per + // role adds up until the planner gives up on the GIN index. The operator goes through + // the inlined function because PDO reads a lone ? as a positional placeholder, and + // doubling it to escape breaks once a named placeholder is repeated, which the cursor + // conditions do. jsonb_exists_any would avoid both but is not indexable. + if ($this->ensurePermissionsFunction($this->getDatabase())) { + $permissions = \array_map( + fn ($role) => $this->getPDO()->quote("{$type}(\"{$role}\")"), + $roles + ); + + return "{$this->getSQLSchema()}{$this->quote(self::PERMISSIONS_FUNCTION)}({$column}, ARRAY[" . \implode(', ', $permissions) . ']::text[])'; + } + $permissions = \array_map( fn ($role) => "{$column} @> {$this->getPDO()->quote(\json_encode(["{$type}(\"{$role}\")"]))}::jsonb", $roles ); - if ($permissions === []) { - return 'FALSE'; + return '(' . \implode(' OR ', $permissions) . ')'; + } + + /** + * Make sure the schema has the function permission checks are written against. + * + * Databases created before it existed get it on first use. Where it can't be created + * (inside a transaction that may yet roll back, or without the rights to), the caller + * falls back to one containment check per role. + * + * @param string $schema + * @return bool + */ + protected function ensurePermissionsFunction(string $schema): bool + { + $pdo = $this->getPDO(); + $schema = $this->filter($schema); + + $known = self::$permissionsFunctions ??= new \WeakMap(); + + if (isset($known[$pdo][$schema])) { + return true; } - return '(' . \implode(' OR ', $permissions) . ')'; + $function = "\"{$schema}\".\"" . self::PERMISSIONS_FUNCTION . '"'; + + try { + $stmt = $pdo->prepare('SELECT to_regprocedure(:function) IS NOT NULL'); + $stmt->bindValue(':function', "{$function}(jsonb, text[])"); + $stmt->execute(); + $exists = (bool) $stmt->fetchColumn(); + + if (!$exists) { + if ($pdo->inTransaction()) { + return false; + } + + // Run through exec, which does not look for placeholders. A concurrent + // creation of the same function is not an error. + $pdo->exec(" + DO \$\$ BEGIN + CREATE FUNCTION {$function}(jsonb, text[]) RETURNS boolean + LANGUAGE sql IMMUTABLE PARALLEL SAFE + AS 'SELECT \$1 OPERATOR(pg_catalog.?|) \$2'; + EXCEPTION WHEN duplicate_function OR unique_violation THEN NULL; + END \$\$ + "); + } + } catch (PDOException) { + return false; + } + + $known[$pdo] = [...($known[$pdo] ?? []), $schema => true]; + + return true; } /** From 833dce653f2e0e20bccede894c3a5739a63f8047 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 6 Oct 2026 12:55:19 +0530 Subject: [PATCH 2/7] fix(postgres): create the permissions function with the database create() now adds the function whether or not the schema already exists, so databases created before it get it the next time create() runs. Permission conditions always call the function, which drops the per-connection existence check, its cache and the fallback to one containment check per role. --- src/Database/Adapter/Postgres.php | 87 +++++++------------------------ 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 4cc594de2..14cab1b8e 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -37,13 +37,6 @@ class Postgres extends SQL */ protected const PERMISSIONS_FUNCTION = '_permissions_any'; - /** - * Schemas known to have the permissions function, per connection. - * - * @var \WeakMap>|null - */ - protected static ?\WeakMap $permissionsFunctions = null; - /** * @inheritDoc */ @@ -130,6 +123,9 @@ public function create(string $name): bool $name = $this->filter($name); if ($this->exists($name)) { + // Schemas created before the function existed get it here. + $this->createPermissionsFunction($name); + return true; } @@ -154,7 +150,7 @@ public function create(string $name): bool "; $this->getPDO()->prepare($collation)->execute(); - $this->ensurePermissionsFunction($name); + $this->createPermissionsFunction($name); return $dbCreation; } @@ -174,10 +170,6 @@ public function delete(string $name): bool $sql = "DROP SCHEMA IF EXISTS \"{$name}\" CASCADE"; $sql = $this->trigger(Database::EVENT_DATABASE_DELETE, $sql); - if (self::$permissionsFunctions !== null) { - unset(self::$permissionsFunctions[$this->getPDO()]); - } - return $this->getPDO()->prepare($sql)->execute(); } @@ -1861,75 +1853,34 @@ protected function getSQLPermissionsCondition( // the inlined function because PDO reads a lone ? as a positional placeholder, and // doubling it to escape breaks once a named placeholder is repeated, which the cursor // conditions do. jsonb_exists_any would avoid both but is not indexable. - if ($this->ensurePermissionsFunction($this->getDatabase())) { - $permissions = \array_map( - fn ($role) => $this->getPDO()->quote("{$type}(\"{$role}\")"), - $roles - ); - - return "{$this->getSQLSchema()}{$this->quote(self::PERMISSIONS_FUNCTION)}({$column}, ARRAY[" . \implode(', ', $permissions) . ']::text[])'; - } - $permissions = \array_map( - fn ($role) => "{$column} @> {$this->getPDO()->quote(\json_encode(["{$type}(\"{$role}\")"]))}::jsonb", + fn ($role) => $this->getPDO()->quote("{$type}(\"{$role}\")"), $roles ); - return '(' . \implode(' OR ', $permissions) . ')'; + return "{$this->getSQLSchema()}{$this->quote(self::PERMISSIONS_FUNCTION)}({$column}, ARRAY[" . \implode(', ', $permissions) . ']::text[])'; } /** - * Make sure the schema has the function permission checks are written against. - * - * Databases created before it existed get it on first use. Where it can't be created - * (inside a transaction that may yet roll back, or without the rights to), the caller - * falls back to one containment check per role. + * Create the function permission checks are written against, unless the schema has it. * * @param string $schema - * @return bool + * @return void */ - protected function ensurePermissionsFunction(string $schema): bool + protected function createPermissionsFunction(string $schema): void { - $pdo = $this->getPDO(); - $schema = $this->filter($schema); - - $known = self::$permissionsFunctions ??= new \WeakMap(); - - if (isset($known[$pdo][$schema])) { - return true; - } - $function = "\"{$schema}\".\"" . self::PERMISSIONS_FUNCTION . '"'; - try { - $stmt = $pdo->prepare('SELECT to_regprocedure(:function) IS NOT NULL'); - $stmt->bindValue(':function', "{$function}(jsonb, text[])"); - $stmt->execute(); - $exists = (bool) $stmt->fetchColumn(); - - if (!$exists) { - if ($pdo->inTransaction()) { - return false; - } - - // Run through exec, which does not look for placeholders. A concurrent - // creation of the same function is not an error. - $pdo->exec(" - DO \$\$ BEGIN - CREATE FUNCTION {$function}(jsonb, text[]) RETURNS boolean - LANGUAGE sql IMMUTABLE PARALLEL SAFE - AS 'SELECT \$1 OPERATOR(pg_catalog.?|) \$2'; - EXCEPTION WHEN duplicate_function OR unique_violation THEN NULL; - END \$\$ - "); - } - } catch (PDOException) { - return false; - } - - $known[$pdo] = [...($known[$pdo] ?? []), $schema => true]; - - return true; + // Run through exec, which does not look for placeholders. A concurrent creation of + // the same function is not an error. + $this->getPDO()->exec(" + DO \$\$ BEGIN + CREATE FUNCTION {$function}(jsonb, text[]) RETURNS boolean + LANGUAGE sql IMMUTABLE PARALLEL SAFE + AS 'SELECT \$1 OPERATOR(pg_catalog.?|) \$2'; + EXCEPTION WHEN duplicate_function OR unique_violation THEN NULL; + END \$\$ + "); } /** From f790ba7c780ce490a2bcdb7ebbf4ee8c284eefe9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 6 Oct 2026 14:05:05 +0530 Subject: [PATCH 3/7] fix(postgres): write ?| inline instead of through a function PDO passes the escaped ??| through under emulated prepares as long as no named placeholder repeats in the statement. The cursor equality conditions now bind per branch, and the projected vector distance binds its own copy of the vector, so the permission condition can use ?| directly. That removes the function and its creation in create(). --- src/Database/Adapter/Postgres.php | 51 ++++++------------------------- src/Database/Adapter/SQL.php | 6 ++-- 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 14cab1b8e..a57960f82 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -32,11 +32,6 @@ class Postgres extends SQL { public const MAX_IDENTIFIER_NAME = 63; - /** - * Function that tests a row's permissions against a set of roles with jsonb's ?| operator. - */ - protected const PERMISSIONS_FUNCTION = '_permissions_any'; - /** * @inheritDoc */ @@ -123,9 +118,6 @@ public function create(string $name): bool $name = $this->filter($name); if ($this->exists($name)) { - // Schemas created before the function existed get it here. - $this->createPermissionsFunction($name); - return true; } @@ -149,9 +141,6 @@ public function create(string $name): bool ) "; $this->getPDO()->prepare($collation)->execute(); - - $this->createPermissionsFunction($name); - return $dbCreation; } @@ -1842,45 +1831,23 @@ protected function getSQLPermissionsCondition( throw new DatabaseException('Unknown permission type: ' . $type); } - if ($roles === []) { - return 'FALSE'; - } - $column = "{$this->quote($alias)}.{$this->quote('_permissions')}"; - // One ?| for all roles keeps the estimate flat however many roles there are; a @> per - // role adds up until the planner gives up on the GIN index. The operator goes through - // the inlined function because PDO reads a lone ? as a positional placeholder, and - // doubling it to escape breaks once a named placeholder is repeated, which the cursor - // conditions do. jsonb_exists_any would avoid both but is not indexable. + // One ?| for all roles; a @> per role adds to the row estimate until the planner gives + // up on the GIN index. ?? is PDO's escape for a literal ?, which emulated prepares only + // accept while no named placeholder appears twice in the statement, so the queries this + // condition joins bind each value under its own name. jsonb_exists_any would avoid the + // ? but is not indexable. $permissions = \array_map( fn ($role) => $this->getPDO()->quote("{$type}(\"{$role}\")"), $roles ); - return "{$this->getSQLSchema()}{$this->quote(self::PERMISSIONS_FUNCTION)}({$column}, ARRAY[" . \implode(', ', $permissions) . ']::text[])'; - } + if ($permissions === []) { + return 'FALSE'; + } - /** - * Create the function permission checks are written against, unless the schema has it. - * - * @param string $schema - * @return void - */ - protected function createPermissionsFunction(string $schema): void - { - $function = "\"{$schema}\".\"" . self::PERMISSIONS_FUNCTION . '"'; - - // Run through exec, which does not look for placeholders. A concurrent creation of - // the same function is not an error. - $this->getPDO()->exec(" - DO \$\$ BEGIN - CREATE FUNCTION {$function}(jsonb, text[]) RETURNS boolean - LANGUAGE sql IMMUTABLE PARALLEL SAFE - AS 'SELECT \$1 OPERATOR(pg_catalog.?|) \$2'; - EXCEPTION WHEN duplicate_function OR unique_violation THEN NULL; - END \$\$ - "); + return "{$column} ??| ARRAY[" . \implode(', ', $permissions) . ']::text[]'; } /** diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9ca4c1aee..b73765bd5 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -3047,7 +3047,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $prevOriginal = $orderAttributes[$j]; $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); - $bindName = ":cursor_{$j}"; + $bindName = ":cursor_{$i}_{$j}"; $binds[$bindName] = $cursor[$prevOriginal]; $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; @@ -3118,7 +3118,9 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $projection = $this->getAttributeProjection($selections, $alias); if (!empty($vectorDistances)) { - $readable = $this->getSQLReadableDistance($vectorDistances[0]); + // Built again so its vector binds under a name the ORDER BY doesn't use. + $distance = $this->getSQLVectorDistance($vectorQueries[0], $binds, $alias) ?? $vectorDistances[0]; + $readable = $this->getSQLReadableDistance($distance); $projection .= ", {$readable} AS {$this->quote(static::VECTOR_DISTANCE_COLUMN)}"; } From ddd4837f3868550bac86ab4566285ecadbd4c03c Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 7 Oct 2026 13:48:33 +1300 Subject: [PATCH 4/7] test(postgres): pin many-role reads to the permissions index One clause per role added a minimum row estimate per role, so with enough roles a selective read looked permissive and the planner walked the primary key instead of the GIN index. With 60 roles over 50k rows the index goes unscanned on main and is scanned with the single ?| condition. Sequential scans are priced out of the session and the table is vacuumed first, so the GIN pending list doesn't skew the cost and the assertion measures the estimate rather than the collection size. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/Adapter/PostgresTest.php | 77 ++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/e2e/Adapter/PostgresTest.php b/tests/e2e/Adapter/PostgresTest.php index 14afc6db7..0b56924a7 100644 --- a/tests/e2e/Adapter/PostgresTest.php +++ b/tests/e2e/Adapter/PostgresTest.php @@ -128,6 +128,83 @@ public function testReadDoesNotTouchThePermissionsTable(): void $database->deleteCollection('permsPlan'); } + /** + * A reader holding many roles must still be answered from the permissions index when few + * documents are readable. Matching each role as its own clause made the planner add up a + * minimum estimate per role, so enough roles convinced it most of the collection was + * readable, and it walked the whole collection in order instead. + * + * Sequential scans are priced out of the session, as in the vector plan test, so the + * assertion measures the estimate rather than the size of the collection. + */ + public function testManyRolesUseThePermissionsIndex(): void + { + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->createCollection('rolesPlan', permissions: [ + Permission::create(Role::any()), + ], documentSecurity: true); + + $database->createAttribute('rolesPlan', 'owner', Database::VAR_INTEGER, 0, true); + + $documents = []; + for ($i = 0; $i < 50000; $i++) { + $documents[] = new Document([ + '$permissions' => [Permission::read(Role::user("owner{$i}"))], + 'owner' => $i, + ]); + } + $database->createDocuments('rolesPlan', $documents, 1000); + + $table = $database->getNamespace() . '_rolesPlan'; + self::$pdo->exec("VACUUM ANALYZE \"{$database->getDatabase()}\".\"{$table}\""); + + $scans = function () use ($table): int { + self::$pdo->query('SELECT pg_stat_force_next_flush()'); + self::$pdo->query('SELECT pg_stat_clear_snapshot()'); + + $statement = self::$pdo->prepare(' + SELECT COALESCE(SUM(statistics.idx_scan), 0) + FROM pg_stat_user_indexes AS statistics + JOIN pg_class AS index ON index.oid = statistics.indexrelid + JOIN pg_am AS method ON method.oid = index.relam + WHERE statistics.relname = :table AND method.amname = \'gin\' + '); + $statement->execute([':table' => $table]); + + return (int)$statement->fetchColumn(); + }; + + $before = $scans(); + + self::$pdo->exec('SET enable_seqscan = off'); + + try { + for ($i = 0; $i < 60; $i++) { + $authorization->addRole(Role::user("stranger{$i}")->toString()); + } + $authorization->addRole(Role::user('owner7')->toString()); + + $results = $database->find('rolesPlan', [Query::limit(25)]); + } finally { + self::$pdo->exec('RESET enable_seqscan'); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + } + + $this->assertCount(1, $results, 'Only the document owned by the reader may come back'); + $this->assertSame(7, $results[0]->getAttribute('owner')); + + $this->assertGreaterThan( + $before, + $scans(), + 'A selective read must be answered from the permissions index however many roles the reader holds' + ); + + $database->deleteCollection('rolesPlan'); + } + /** * A vector search must order by distance alone, because a vector index can answer exactly * one sort key. Adding a second one does not merely make the index look expensive, it makes From 4dad6d118ccfe4f1e3096b45bd58f91a29f05244 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 8 Oct 2026 16:43:41 +0530 Subject: [PATCH 5/7] fix(postgres): bind each distance placeholder under its own name distanceLessThan reused its point and distance placeholders for the index condition. With the ?| permissions check in the same statement, emulated prepares reject the repeat, so every document security read with a distance query failed with HY093. --- src/Database/Adapter/Postgres.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 0e8fbb3c4..93265f7e9 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1510,8 +1510,10 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str $binds[":{$placeholder}_2"] = $degrees[0]; $binds[":{$placeholder}_3"] = $degrees[1]; + // Each use binds under its own name: the permissions condition's ?? fails once a placeholder repeats + $binds[":{$placeholder}_4"] = $binds[":{$placeholder}_0"]; - return "{$alias}.{$attribute} && ST_Expand(" . $this->getSpatialGeomFromText(":{$placeholder}_0") . ", :{$placeholder}_2, :{$placeholder}_3) AND {$distance}"; + return "{$alias}.{$attribute} && ST_Expand(" . $this->getSpatialGeomFromText(":{$placeholder}_4") . ", :{$placeholder}_2, :{$placeholder}_3) AND {$distance}"; } // Without meters, use the original SRID (e.g., 4326) @@ -1519,7 +1521,11 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str // ST_DWithin can use the GIST index; ST_Distance keeps the boundary exclusive if ($within) { - return "ST_DWithin({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ", :{$placeholder}_1) AND {$distance}"; + // Each use binds under its own name, as above + $binds[":{$placeholder}_2"] = $binds[":{$placeholder}_0"]; + $binds[":{$placeholder}_3"] = $binds[":{$placeholder}_1"]; + + return "ST_DWithin({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_2") . ", :{$placeholder}_3) AND {$distance}"; } return $distance; From 13d2e97259fb7d8b00da7d514d842497946df97c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 8 Oct 2026 16:44:51 +0530 Subject: [PATCH 6/7] perf(postgres): match read("any") apart from the other roles A lone ?| is priced like a single equality, so the planner checked it first on every row and unindexed filters ran after it. OR-ing a @> for "any" in front of the ?| prices the check as two conditions, so the reader's own filters narrow the rows before permissions are checked. --- src/Database/Adapter/Postgres.php | 36 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 93265f7e9..91731a692 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1898,21 +1898,35 @@ protected function getSQLPermissionsCondition( $column = "{$this->quote($alias)}.{$this->quote('_permissions')}"; - // One ?| for all roles; a @> per role adds to the row estimate until the planner gives - // up on the GIN index. ?? is PDO's escape for a literal ?, which emulated prepares only - // accept while no named placeholder appears twice in the statement, so the queries this - // condition joins bind each value under its own name. jsonb_exists_any would avoid the - // ? but is not indexable. - $permissions = \array_map( - fn ($role) => $this->getPDO()->quote("{$type}(\"{$role}\")"), - $roles - ); + $conditions = []; + + // A lone ?| is priced like a single equality, so the planner checks it first on every + // row. Matching "any" on its own makes the check cost two conditions, and the reader's + // own filters run before it. + if (\in_array('any', $roles, true)) { + $conditions[] = "{$column} @> " . $this->getPDO()->quote(\json_encode(["{$type}(\"any\")"])) . '::jsonb'; + $roles = \array_values(\array_diff($roles, ['any'])); + } + + // One ?| for the other roles; a @> per role adds to the row estimate until the planner + // gives up on the GIN index. ?? is PDO's escape for a literal ?, which emulated prepares + // only accept while no named placeholder appears twice in the statement, so the queries + // this condition joins bind each value under its own name. jsonb_exists_any would avoid + // the ? but is not indexable. + if ($roles !== []) { + $permissions = \array_map( + fn ($role) => $this->getPDO()->quote("{$type}(\"{$role}\")"), + $roles + ); + + $conditions[] = "{$column} ??| ARRAY[" . \implode(', ', $permissions) . ']::text[]'; + } - if ($permissions === []) { + if ($conditions === []) { return 'FALSE'; } - return "{$column} ??| ARRAY[" . \implode(', ', $permissions) . ']::text[]'; + return '(' . \implode(' OR ', $conditions) . ')'; } /** From 22b2a3b166d4e00740af5b41293ef3ca66129a10 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 8 Oct 2026 16:45:04 +0530 Subject: [PATCH 7/7] perf(postgres): create the permissions GIN index without fastupdate With fastupdate on, new rows wait in the GIN pending list, and every lookup scans that list until vacuum flushes it. Inserting into the index directly keeps permission reads fast right after writes, for a small cost on bulk inserts. --- src/Database/Adapter/Postgres.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 91731a692..de116edb7 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -233,7 +233,7 @@ public function createCollection(string $name, array $attributes = [], array $in CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\"); CREATE INDEX \"{$tenantIdIndex}\" ON {$this->getSQLTable($id)} (_tenant, _id); - CREATE INDEX \"{$permissionsIndex}\" ON {$this->getSQLTable($id)} USING gin (_permissions); + CREATE INDEX \"{$permissionsIndex}\" ON {$this->getSQLTable($id)} USING gin (_permissions) WITH (fastupdate = off); "; } else { $uidIndex = $this->getShortKey("{$namespace}_{$id}_uid"); @@ -244,7 +244,7 @@ public function createCollection(string $name, array $attributes = [], array $in CREATE UNIQUE INDEX \"{$uidIndex}\" ON {$this->getSQLTable($id)} (\"_uid\" COLLATE utf8_ci_ai); CREATE INDEX \"{$createdIndex}\" ON {$this->getSQLTable($id)} (\"_createdAt\"); CREATE INDEX \"{$updatedIndex}\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); - CREATE INDEX \"{$permissionsIndex}\" ON {$this->getSQLTable($id)} USING gin (_permissions); + CREATE INDEX \"{$permissionsIndex}\" ON {$this->getSQLTable($id)} USING gin (_permissions) WITH (fastupdate = off); "; }