From cefa13fcdf5d74d77e703fe84a3df59d2e6a37ad Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 15 May 2026 11:14:50 +0100 Subject: [PATCH 1/2] perf(adapter): rewrite permissions condition as EXISTS instead of IN subquery The previous IN (SELECT ... FROM _perms) pattern forces MySQL to materialize the entire matching permission set per query. Rewriting as correlated EXISTS lets the planner short-circuit at the first matching _perms row per outer document, which directly reduces innodb_rows_read per SELECT on authenticated reads. Also adds an early '1 = 0' return for empty roles to avoid emitting the invalid 'IN ()' SQL fragment for anonymous users. The method signature is unchanged so all callers (find, count, sum) and adapter subclasses (MariaDB, Postgres, MySQL) inherit the fix without modification. --- src/Database/Adapter/SQL.php | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 4d640c900..6c129d2b4 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1914,6 +1914,9 @@ protected function getSQLIndexType(string $type): string /** * Get SQL condition for permissions * + * Uses correlated EXISTS so the planner can short-circuit at the first matching + * _perms row per outer document, instead of materializing IN (subquery). + * * @param string $collection * @param array $roles * @param string $alias @@ -1931,15 +1934,24 @@ protected function getSQLPermissionsCondition( throw new DatabaseException('Unknown permission type: ' . $type); } + if (empty($roles)) { + return '1 = 0'; + } + $roles = \array_map(fn ($role) => $this->getPDO()->quote($role), $roles); $roles = \implode(', ', $roles); - return "{$this->quote($alias)}.{$this->quote('_uid')} IN ( - SELECT _document - FROM {$this->getSQLTable($collection . '_perms')} - WHERE _permission IN ({$roles}) - AND _type = '{$type}' - {$this->getTenantQuery($collection)} + $outerAlias = $this->quote($alias); + $permsTable = $this->getSQLTable($collection . '_perms'); + $tenantClause = $this->getTenantQuery($collection, 'perms'); + + return "EXISTS ( + SELECT 1 + FROM {$permsTable} AS perms + WHERE perms._document = {$outerAlias}._uid + AND perms._permission IN ({$roles}) + AND perms._type = '{$type}' + {$tenantClause} )"; } From 85aa9b04f1cca377823814d11fc304b8d3d0be17 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 15 May 2026 11:22:57 +0100 Subject: [PATCH 2/2] fix(adapter): use sentinel alias '_perms_inner' to avoid potential collision Using the unquoted string 'perms' as the inner alias would collide if a caller ever passed 'perms' as the outer alias. Switching to a leading- underscore sentinel ('_perms_inner') makes the rewrite robust to any future caller-provided alias, since outer aliases come from Query::DEFAULT_ALIAS ('main') or explicit user input, neither of which uses the underscore prefix convention. --- src/Database/Adapter/SQL.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 6c129d2b4..4631acd78 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1943,14 +1943,17 @@ protected function getSQLPermissionsCondition( $outerAlias = $this->quote($alias); $permsTable = $this->getSQLTable($collection . '_perms'); - $tenantClause = $this->getTenantQuery($collection, 'perms'); + // Leading-underscore sentinel to avoid collisions with caller-provided aliases + $innerAlias = '_perms_inner'; + $innerAliasQuoted = $this->quote($innerAlias); + $tenantClause = $this->getTenantQuery($collection, $innerAlias); return "EXISTS ( SELECT 1 - FROM {$permsTable} AS perms - WHERE perms._document = {$outerAlias}._uid - AND perms._permission IN ({$roles}) - AND perms._type = '{$type}' + FROM {$permsTable} AS {$innerAliasQuoted} + WHERE {$innerAliasQuoted}._document = {$outerAlias}._uid + AND {$innerAliasQuoted}._permission IN ({$roles}) + AND {$innerAliasQuoted}._type = '{$type}' {$tenantClause} )"; }