Skip to content

Conversation

@fogelito
Copy link
Contributor

@fogelito fogelito commented Nov 20, 2025

Summary by CodeRabbit

  • New Features

    • Join support (inner/left/right) with aliasing and relation-based comparisons.
    • QueryContext introduced to carry selections, permissions, and tenant scope across operations.
  • Behavior Changes / Refactor

    • Selection semantics switched to per-field selects; projections and internal field exposure adjusted.
    • Core read/query flows moved to context-driven processing; validators reworked (new V2 validator) and many legacy validators disabled.
  • Tests

    • Expanded join end-to-end coverage; tests updated to use QueryContext and per-field selects.

✏️ Tip: You can customize this high-level summary in your review settings.

Copy link
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Database.php (1)

4980-5013: Nested relationship selects are incorrectly pruned in applySelectFiltersToDocuments, breaking multi‑level relationship loading.

applySelectFiltersToDocuments() treats every select’s getAttribute() as a concrete field name (Lines 4987‑4991). For nested paths like "employee.company.name" the first‑level relationship batch receives a select with attribute "company.name". That key never exists on the related documents, so this logic removes all non‑internal attributes, including the relationship attribute (company), and subsequent relationship population for deeper levels cannot work.

Example:

  • User selects project.employee.company.name.
  • For the employee relationship, selectQueries contains Query::select('company.name').
  • attributesToKeep['company.name'] = true, but employee docs only have company, so company is pruned.
  • The next breadth‑first step can no longer traverse company from employee.

Restricting this pruning to top‑level attributes (no dots) fixes the issue while leaving deeper filtering to subsequent levels.

Suggested fix:

-        // Collect all attributes to keep from select queries
-        $attributesToKeep = [];
-        foreach ($selectQueries as $selectQuery) {
-            $attributesToKeep[$selectQuery->getAttribute()] = true;
-        }
+        // Collect all attributes to keep from select queries
+        // Only consider top‑level attributes here; nested paths (with ".")
+        // are handled when processing deeper relationship levels.
+        $attributesToKeep = [];
+        foreach ($selectQueries as $selectQuery) {
+            $attribute = $selectQuery->getAttribute();
+            if (\str_contains($attribute, '.')) {
+                continue;
+            }
+            $attributesToKeep[$attribute] = true;
+        }

This preserves behavior for simple selects and *, while allowing multi‑level relationship selects to work correctly.

♻️ Duplicate comments (8)
tests/e2e/Adapter/Scopes/JoinsTests.php (2)

79-84: Remove unused variable assignments.

The variables $sessionNoPermissions (line 79) and $session3 (line 122) are assigned but never referenced. Since only the document creation side effect is needed, drop the assignments.

Apply this diff:

-        $sessionNoPermissions = $db->createDocument('__sessions', new Document([
+        $db->createDocument('__sessions', new Document([
             'user_id' => $user1->getId(),
             'user_internal_id' => $user1->getSequence(),
             '$permissions' => [],
         ]));
-        $session3 = $db->createDocument('__sessions', new Document([
+        $db->createDocument('__sessions', new Document([
             'user_id' => $user2->getId(),
             'user_internal_id' => $user2->getSequence(),
             '$permissions' => [
                 Permission::read(Role::any()),
             ],
             'boolean' => true,
             'float' => 5.5,
         ]));

Also applies to: 122-130


771-771: Fix spacing in return type declaration.

Missing space before the colon in the return type declaration. PSR-12 requires a space between the closing parenthesis and the colon.

Apply this diff:

-    public function testJoinsScopeOrder():void
+    public function testJoinsScopeOrder(): void
src/Database/Adapter/SQL.php (2)

3116-3118: Quote identifiers in right join permission clause.

Line 3117 uses raw {$alias}._uid IS NULL without quoting, which is inconsistent with the permission condition that uses $this->quote($alias) in getSQLPermissionsCondition(). This was flagged in a previous review.

Apply this diff to fix the quoting:

         $skipAuth = $context->skipAuth($name, $forPermission, $this->authorization);
         if (! $skipAuth) {
             $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission);
             if ($rightJoins) {
-                $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)";
+                $permissionsCondition = sprintf(
+                    '(%s OR %s.%s IS NULL)',
+                    $permissionsCondition,
+                    $this->quote($alias),
+                    $this->quote('_uid')
+                );
             }
             $where[] = $permissionsCondition;
         }

3275-3281: Quote identifiers in right join permission clause.

Line 3279 uses raw {$alias}._uid IS NULL without quoting, identical to the issue in the find method. This is inconsistent with the quoted identifiers in getSQLPermissionsCondition().

Apply this diff to fix the quoting:

         $skipAuth = $context->skipAuth($name, Database::PERMISSION_READ, $this->authorization);
         if (! $skipAuth) {
             $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias, Database::PERMISSION_READ);
             if ($rightJoins) {
-                $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)";
+                $permissionsCondition = sprintf(
+                    '(%s OR %s.%s IS NULL)',
+                    $permissionsCondition,
+                    $this->quote($alias),
+                    $this->quote('_uid')
+                );
             }
             $where[] = $permissionsCondition;
         }
src/Database/Database.php (1)

8712-8788: Remove unused $idAdded in processRelationshipQueries and keep $id auto‑select relationship‑only.

The tail of processRelationshipQueries() still captures $idAdded from Query::addSelect(...) but never uses it (Lines 8783‑8785), which PHPMD correctly flags and which we’ve previously discussed.

Given the intent is to:

  • Always ensure $id is selected when there are relationship attributes, and
  • Avoid unused locals,

you can safely drop $idAdded and only capture the first returned element:

-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = Query::addSelect($queries, Query::select('$id', system: true));
-        }
+        /**
+         * In order to populateDocumentsRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = Query::addSelect($queries, Query::select('$id', system: true));
+        }

Behavior (auto‑adding $id only when relationships exist) remains unchanged, while satisfying PHPMD.
Based on learnings, this matches the prior guidance for this method.

src/Database/Validator/Queries/V2.php (3)

676-691: Validate alias and guard schema access for SELECT *.

When $query->getAttribute() === '*', the code fetches the collection by alias but doesn't verify it exists before accessing the schema. If the alias is unknown, getCollectionByAlias() returns an empty Document, $collection->getId() is null, and $this->schema[null] triggers undefined index errors.

Add validation and a guard:

 if ($query->getAttribute() === '*') {
     $collection = $this->context->getCollectionByAlias($query->getAlias());
+    if ($collection->isEmpty()) {
+        throw new \Exception('Invalid query: Unknown alias "'.$query->getAlias().'"');
+    }
+
-    $attributes = $this->schema[$collection->getId()];
+    $attributes = $this->schema[$collection->getId()] ?? [];
+    if (empty($attributes)) {
+        throw new \Exception('Invalid query: No schema found for collection "'.$collection->getId().'"');
+    }
+
     foreach ($attributes as $attribute) {

This ensures SELECT * fails fast for invalid aliases and prevents iteration over null/undefined data.


532-536: Join-scope alias check is too broad and will reject valid queries.

This block runs for every query when $scope === 'joins', not just TYPE_RELATION_EQUAL. For normal filter or order queries within a join, getRightAlias() is typically empty, so in_array('', $this->joinsAliasOrder) fails and valid queries are incorrectly rejected.

Since you removed the dedicated validateJoinAliasOrder() helper (per your earlier comment), you have two options:

Option 1 (recommended): Remove this block entirely. The isRelationExist() check at line 638 already ensures at least one relation query is present in each join, and relation queries validate both aliases in the TYPE_RELATION_EQUAL case (lines 648–649).

-if ($scope === 'joins') {
-    if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)) {
-        throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' alias reference in join has not been defined.');
-    }
-}

Option 2: Restrict this check to TYPE_RELATION_EQUAL queries only:

-if ($scope === 'joins') {
+if ($scope === 'joins' && $query->getMethod() === Query::TYPE_RELATION_EQUAL) {
     if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)) {

210-225: Guard against undefined array access in schemaless mode.

When $supportForAttributes is false and the attribute is not in the schema, $attribute is an empty array. Lines 219 and 223 then access $attribute['type'] without a guard, which will trigger undefined index notices and potentially throw incorrect "Only nested relationships allowed" errors.

Adopt the same early-return pattern used in validateValues() (line 292–294):

 $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
-if (empty($attribute) && $this->supportForAttributes) {
-    throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
-}
-
-if (\in_array('encrypt', $attribute['filters'] ?? [])) {
+
+if (empty($attribute)) {
+    if ($this->supportForAttributes) {
+        throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
+    }
+    // Schemaless: skip attribute-based checks for unknown attributes
+    return;
+}
+
+if (\in_array('encrypt', $attribute['filters'] ?? [])) {
     throw new \Exception('Cannot query encrypted attribute: ' . $attributeId);
 }

This makes schemaless behavior consistent and avoids dereferencing an empty array.

🧹 Nitpick comments (5)
src/Database/Adapter/SQL.php (2)

375-375: Clarify the $queries parameter type in getDocument.

The getDocument method signature shows Query[] $queries = [] (line 359), but line 375 passes $queries directly to getAttributeProjection(array $selects). The method expects an array of Query objects with select semantics, but the parameter name and usage are ambiguous.

Consider renaming the parameter to $selects for clarity, or add a PHPDoc comment clarifying that $queries should contain Query objects of type TYPE_SELECT.


2346-2346: Address the TODO comment for spatial attributes.

The TODO comment on line 2346 indicates that spatial attributes handling is incomplete. Spatial attributes may require special SQL functions (like ST_AsText or ST_AsBinary) in the SELECT projection to properly return geometry data.

Do you want me to help implement spatial attribute handling in the projection, or should this be tracked in a separate issue?

src/Database/Database.php (2)

34-35: QueryContext + V2 DocumentsValidator wiring for getDocument looks coherent; consider documenting the “selects‑only” contract.

The new QueryContext/DocumentsValidator flow in getDocument() (context creation, select extraction, relationship processing, caching keyed by $selects, and context‑aware casting()/decode()) is consistent with the new join model and looks solid.

One behavioral change is that $queries are now enforced to be only TYPE_SELECT (mismatch throws QueryException at Lines 4300‑4303). It would be helpful to update the docblock for getDocument() to state that $queries are select projections only, not filters/orders/limits, so callers don’t accidentally trip the validator.

Also applies to: 4275-4417


8086-8137: sum() uses QueryContext/V2 validator but doesn’t yet build join context; verify whether joins should be supported.

sum() now validates queries against a QueryContext (Lines 8088‑8103) but:

  • Only adds the base collection to the context (Lines 8088‑8091).
  • Does not collect join queries into the context or propagate joins into the adapter call, unlike find()/count().

As a result, any join/alias‑based queries passed into sum() are likely to fail validation (unknown alias) or be ignored by adapters.

If sum() is intentionally restricted to single‑collection queries, that’s fine; it may be worth documenting this. If the intent is “joins work everywhere,” consider mirroring the join/context wiring from count() and, if needed, extending the adapter sum() signature to accept QueryContext and joins.

src/Database/Validator/Queries/V2.php (1)

740-741: Remove commented-out debug code.

The var_dump() calls are commented out but should be deleted entirely for cleaner code.

 } catch (\Throwable $e) {
     $this->message = $e->getMessage();
-    //var_dump($this->message);
-    //var_dump($e);
-
     return false;
 }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2c32097 and c38aa72.

📒 Files selected for processing (4)
  • src/Database/Adapter/SQL.php (12 hunks)
  • src/Database/Database.php (55 hunks)
  • src/Database/Validator/Queries/V2.php (1 hunks)
  • tests/e2e/Adapter/Scopes/JoinsTests.php (1 hunks)
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Database.php
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Database.php
  • tests/e2e/Adapter/Scopes/JoinsTests.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Adapter/SQL.php
  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Database.php
  • tests/e2e/Adapter/Scopes/JoinsTests.php
📚 Learning: 2025-10-29T12:27:57.071Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 747
File: src/Database/Adapter/Mongo.php:1449-1453
Timestamp: 2025-10-29T12:27:57.071Z
Learning: In src/Database/Adapter/Mongo.php, when getSupportForAttributes() returns false (schemaless mode), the updateDocument method intentionally uses a raw document without $set operator for replacement-style updates, as confirmed by the repository maintainer ArnabChatterjee20k.

Applied to files:

  • src/Database/Validator/Queries/V2.php
🧬 Code graph analysis (2)
src/Database/Validator/Queries/V2.php (4)
src/Database/Database.php (1)
  • Database (38-9411)
src/Database/Document.php (1)
  • Document (12-470)
src/Database/Query.php (7)
  • Query (8-1599)
  • getAttribute (255-258)
  • getAlias (277-280)
  • getMethod (247-250)
  • getValues (263-266)
  • parse (487-500)
  • getAs (292-295)
src/Database/QueryContext.php (3)
  • QueryContext (8-89)
  • getCollections (32-35)
  • getCollectionByAlias (37-55)
src/Database/Database.php (2)
src/Database/Validator/Queries/V2.php (3)
  • V2 (23-748)
  • isValid (498-747)
  • getDescription (135-138)
src/Database/QueryContext.php (6)
  • QueryContext (8-89)
  • add (60-68)
  • getCollections (32-35)
  • skipAuth (75-86)
  • addSkipAuth (70-73)
  • getCollectionByAlias (37-55)
🪛 GitHub Actions: Linter
tests/e2e/Adapter/Scopes/JoinsTests.php

[error] 1-1: PSR-12: return_type_declaration style issue detected by Pint lint. File contains a return type declaration that violates PSR-12 rules.

🪛 PHPMD (2.15.0)
src/Database/Database.php

8784-8784: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

tests/e2e/Adapter/Scopes/JoinsTests.php

79-79: Avoid unused local variables such as '$sessionNoPermissions'. (undefined)

(UnusedLocalVariable)


122-122: Avoid unused local variables such as '$session3'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Unit Test
🔇 Additional comments (16)
src/Database/Adapter/SQL.php (6)

2302-2335: LGTM! Enhanced tenant query flexibility.

The added $condition and $forceIsNull parameters allow proper handling of right joins and flexible query composition. The empty string option for $condition enables clean integration when the caller manages the AND/OR logic.


1800-1806: LGTM! Join type mapping correctly implemented.

The join type constants are properly mapped to their SQL keywords. This addresses the previous review concern about hardcoded INNER JOIN usage.


3017-3079: LGTM! Cursor pagination properly handles join aliases.

The ordering and cursor pagination logic correctly uses getAlias() from order queries, enabling ordering by attributes from joined collections. The tie-breaking logic for multi-attribute cursors is preserved.


3131-3141: LGTM! Vector ordering properly integrated.

Vector distance calculations are correctly prepended to the ORDER BY clause, ensuring similarity search results are ordered by relevance first before any secondary ordering.


3170-3177: LGTM! Float precision handling prevents data loss.

Binding floats as strings with getFloatPrecision() preserves full precision during SQL parameter binding, avoiding floating-point rounding issues.


3234-3329: LGTM! Count method properly implements QueryContext and join support.

The count method correctly:

  • Uses QueryContext for collection and auth information
  • Implements join support with proper SQL construction
  • Handles right joins with tenant query adjustments
  • Provides consistent error handling with try-catch
src/Database/Database.php (8)

5118-5123: Context‑aware casting/decoding after create paths is consistent with read paths.

Creating a QueryContext, adding the collection, and then running casting($context, …) and decode($context, …) in both createDocument() and createDocuments() aligns the write return types with getDocument()/find(). This should keep consumers from seeing different scalar types depending on which CRUD path they use.

Also applies to: 5171-5173, 5234-5238


5591-5831: Update/upsert paths only call adapter‑level casting; ensure this keeps return types consistent with create/find.

updateDocument(), updateDocuments(), and upsertDocumentsWithIncrease() now:

  • Run $this->adapter->castingAfter($collection, $document) / $doc after persistence.
  • Then decode via decode($context, …) but do not call Database::casting($context, …) on their results (unlike getDocument(), createDocument(), createDocuments(), and find()).

If all adapters now perform full scalar normalization (bool/int/float) inside castingAfter() for these paths, this is fine. If any adapter still relies on Database::casting() for normalization (especially SQL adapters), these update/upsert return values could diverge in type from create/find results.

It’s worth double‑checking adapter implementations to confirm that:

  • castingAfter() is doing the same scalar normalization for updates/upserts as reads, or
  • No adapter depends on Database::casting() for normalization in these code paths.

If not, consider adding a casting($context, …) call before decode() here for parity.

Also applies to: 5854-6076, 6544-6831


7496-7588: deleteDocuments joins/context/validation wiring matches updateDocuments and looks good.

The new QueryContext + DocumentsValidator usage in deleteDocuments() is consistent with updateDocuments() and find():

  • Context collects base + joined collections, with per‑collection auth (via addSkipAuth).
  • Queries are type‑checked, validated with V2, then grouped.
  • Required attributes $permissions and $sequence are explicitly enforced before proceeding (Lines 7581‑7588), which is critical for permission cleanup and engine‑level deletes.

This batch delete pipeline looks coherent with the rest of the new query model.


7742-7893: Join‑aware find() pipeline using QueryContext and V2 validator is well‑structured.

The new find() flow:

  • Builds a QueryContext with base collection + all join targets/aliases (Lines 7750‑7761).
  • Computes per‑collection skipAuth and records it in the context (Lines 7762‑7771), so adapters can make permission‑aware decisions.
  • Validates the full query set (including joins/nested) with DocumentsValidator V2 using that context (Lines 7775‑7784).
  • Converts queries through convertQueries($context, …) (fixing the earlier static call issue), then fans out selects, filters, joins, vectors, orders and passes them, along with QueryContext, into adapter->find() (Lines 7795‑7865).
  • Runs adapter castingAfter, then context‑aware casting() and decode() per document (Lines 7874‑7878).

Overall, this matches the intended context‑first/joins model and looks correct.


7992-8071: count() now mirrors find()’s join/context model; max guard is sensible.

The updated count():

  • Validates $max to be null or >= 1 (Lines 7994‑7997), preventing silent misuse of negative/zero values.
  • Uses QueryContext + join discovery exactly like find() (Lines 8004‑8015), including per‑collection READ auth and addSkipAuth.
  • Validates queries with DocumentsValidator V2 against the same context (Lines 8030‑8038).
  • Runs convertQueries($context, …), extracts filters, converts relationship filters to subqueries, and passes everything, plus joins and context, into adapter->count() (Lines 8049‑8066).

This is a clean, join‑aware implementation of count and matches the rest of the new query pipeline.


8240-8446: Context‑aware decode() and casting() correctly handle aliases, AS, and multi‑collection schemas.

The new implementations:

  • Build an internal + per‑collection schema once per call (Lines 8242‑8255, 8344‑8357).
  • For each field in the raw document, resolve:
    • The effective alias and attribute name, honoring AS (getAs()) and adapter‑filtered column names (Lines 8263‑8277, 8365‑8379).
    • The target collection via QueryContext::getCollectionByAlias() (Lines 8279‑8283, 8381‑8385).
    • The attribute metadata from either internal or per‑collection schema (Lines 8284‑8288, 8386‑8390).
  • In decode(), apply registered filters in reverse order per attribute and rebuild a new document keyed by logical attribute IDs / aliases (Lines 8313‑8324).
  • In casting(), short‑circuit when the adapter doesn’t support casting (Lines 8340‑8342) and otherwise perform scalar normalization based on attribute type, including array handling and JSON decoding for array‑typed columns (Lines 8405‑8438).

This design should handle joined projections, aliased columns, and multi‑collection result sets cleanly.


8547-8615: convertQueries/convertQuery now correctly use QueryContext and respect aliases.

The refactor to:

  • Thread QueryContext through convertQueries() and convertQuery() (Lines 8547‑8567).
  • Treat nested/join queries by recursively converting their value arrays (Lines 8549‑8553).
  • Resolve the right collection via getCollectionByAlias($query->getAlias()) and throw if the alias is unknown (Lines 8572‑8576).
  • Attach onArray and attributeType hints from the resolved attribute metadata and normalize datetime values using adapter UTC behavior (Lines 8595‑8611),

brings these helpers in line with the new join/alias model and fixes the earlier static‑call/$this issue.


8806-9180: Nested relationship filter → subquery conversion is sophisticated but generally consistent; rely on tests to guard edge cases.

The new processNestedRelationshipPath() and convertRelationshipFiltersToSubqueries():

  • Parse dot‑path filters, group them by relationship chain, and walk the relationship graph from the deepest collection back to the start to derive matching IDs (Lines 8808‑8952).
  • Use existing relationship metadata (relationType, side, twoWayKey) to decide when reverse lookups or junction traversals are needed, including many‑to‑many via junction collections.
  • Convert complex relationship filters into equivalent Query::equal(...) constraints on either $id or the appropriate relationship key, returning null early when no matches are possible (Lines 8977‑9180).

Given the complexity and number of cases (1‑1, 1‑n, n‑1, n‑n, multi‑level chains), this is the right shape but heavily relies on tests for safety. It would be good to ensure you have explicit coverage for:

  • Multi‑hop paths (e.g. project.employee.company.name),
  • Each relation type/side,
  • Filters that intentionally match nothing (confirm they short‑circuit to null/0 quickly).
src/Database/Validator/Queries/V2.php (2)

56-128: LGTM! Schema construction is solid.

The constructor correctly clones collections from the context to avoid mutations and systematically adds system attributes to each collection's schema. The indexing approach (by collection ID and attribute key) aligns well with the validation logic downstream.


323-323: Sequence validator logic corrected.

The Sequence validator now correctly uses $attributeId === '$sequence' (not $attribute === '$sequence') to set the primary flag. This aligns with the pattern in Structure.php line 343 and properly validates ID-like attributes.

Copy link
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Adapter/SQL.php (1)

3017-3077: Cursor pagination ignores per-order aliases in tie‑break conditions

In the cursor block, equality predicates for “previous” order attributes always use $orderAlias (the current order’s alias):

for ($j = 0; $j < $i; $j++) {
    $prevQuery = $orderQueries[$j];
    $prevOriginal = $prevQuery->getAttribute();
    $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal));
    ...
    $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}";
}

If different orderQueries use different aliases (e.g., main vs joined collection), the cursor WHERE clause will compare the wrong table alias for earlier attributes, breaking lexicographic pagination and potentially producing incorrect result windows.

You can fix this by using each previous query’s own alias:

-            // Add equality conditions for previous attributes
-            for ($j = 0; $j < $i; $j++) {
-                $prevQuery = $orderQueries[$j];
-                $prevOriginal = $prevQuery->getAttribute();
-                $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal));
-
-                $bindName = ":cursor_{$j}";
-                $binds[$bindName] = $cursor[$prevOriginal];
-
-                $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}";
-            }
+            // Add equality conditions for previous attributes, respecting aliases per order query
+            for ($j = 0; $j < $i; $j++) {
+                /** @var Query $prevQuery */
+                $prevQuery = $orderQueries[$j];
+                $prevAlias = $prevQuery->getAlias();
+                $prevOriginal = $prevQuery->getAttribute();
+                $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal));
+
+                $bindName = ":cursor_{$j}";
+                $binds[$bindName] = $cursor[$prevOriginal];
+
+                $conditions[] = "{$this->quote($prevAlias)}.{$this->quote($prevAttr)} = {$bindName}";
+            }
♻️ Duplicate comments (3)
tests/e2e/Adapter/Scopes/JoinsTests.php (2)

79-84: Remove unused variable assignments.

$sessionNoPermissions and $session3 are assigned but never referenced. Since only the side-effect of inserting documents is needed, drop the assignments.

Also applies to: 122-130


514-589: Commented-out tests for ambiguous/duplicate column selection.

This block was previously flagged. Consider tracking these as a separate issue if the validation logic is planned for future implementation.

src/Database/Adapter/SQL.php (1)

3085-3125: RIGHT JOIN permission/tenant handling: alias quoting + skipAuth keying

Two related points in the JOIN + permissions paths:

  1. RIGHT JOIN permission clause uses unquoted alias/column

In both find() and count() you extend the main collection’s permission condition for right joins:

$permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission);
// ...
if ($rightJoins) {
    $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)";
}

and similarly in count().

getSQLPermissionsCondition() itself uses quoted identifiers ({$this->quote($alias)}.{$this->quote('_uid')}), but the added OR branch uses raw {$alias}._uid. This is inconsistent and may break if alias quoting ever changes or if aliases contain special characters.

You can build the OR branch with the same quoting:

-        $skipAuth = $context->skipAuth($name, $forPermission, $this->authorization);
-        if (! $skipAuth) {
-            $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission);
-            if ($rightJoins) {
-                $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)";
-            }
-            $where[] = $permissionsCondition;
-        }
+        $skipAuth = $context->skipAuth($name, $forPermission, $this->authorization);
+        if (! $skipAuth) {
+            $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission);
+            if ($rightJoins) {
+                $permissionsCondition = \sprintf(
+                    '(%s OR %s.%s IS NULL)',
+                    $permissionsCondition,
+                    $this->quote($alias),
+                    $this->quote('_uid'),
+                );
+            }
+            $where[] = $permissionsCondition;
+        }

and analogously in count() (without $forPermission argument).

  1. skipAuth() is called with filtered collection names

For both joins and the root collection you do:

$collection = $join->getCollection();
$collection = $this->filter($collection);

$skipAuth = $context->skipAuth($collection, $forPermission, $this->authorization);
...
$name = $context->getCollections()[0]->getId();
$name = $this->filter($name);
...
$skipAuth = $context->skipAuth($name, $forPermission, $this->authorization);

If QueryContext::addSkipAuth() is keyed by the raw collection id (e.g. $collection->getId() in Database), using the filtered value here would cause skipAuth() to miss entries and accidentally apply permission predicates where skip-auth was requested.

A safer pattern would be to keep the raw id for context lookups and use the filtered string only for SQL identifiers, for example:

-        foreach ($joins as $join) {
-            $permissions = '';
-            $collection = $join->getCollection();
-            $collection = $this->filter($collection);
-
-            $skipAuth = $context->skipAuth($collection, $forPermission, $this->authorization);
+        foreach ($joins as $join) {
+            $permissions = '';
+            $collectionId = $join->getCollection();
+            $collection = $this->filter($collectionId);
+
+            $skipAuth = $context->skipAuth($collectionId, $forPermission, $this->authorization);

and similarly for the root collection in both find() and count() (keep $rootCollectionId = $context->getCollections()[0]->getId(); for skipAuth(), pass the filtered $name only into SQL helpers).

Would you double‑check how QueryContext::addSkipAuth() is keyed (raw vs filtered id) and confirm that changing skipAuth() calls to use the raw collection id won’t break existing behaviour?

To help verify, you can run something like:

#!/bin/bash
# Inspect QueryContext skipAuth/addSkipAuth usage and keys.
rg -n "class QueryContext|addSkipAuth\(|skipAuth\(" src/Database -C3

Also applies to: 3249-3287

🧹 Nitpick comments (2)
tests/e2e/Adapter/Scopes/JoinsTests.php (1)

44-44: Remove commented-out code.

This commented-out Authorization::setRole call appears to be leftover debugging/development code and should be removed for cleanliness.

-        //Authorization::setRole('user:bob');
src/Database/Adapter/SQL.php (1)

364-379: Clarify getDocument()’s $queries contract vs getAttributeProjection()

getDocument() now builds its projection via:

$sql = "
    SELECT {$this->getAttributeProjection($queries)}
    FROM {$this->getSQLTable($name)} AS {$this->quote($alias)}
    WHERE {$this->quote($alias)}._uid = :_uid 
    {$this->getTenantQuery($collection, $alias)}
";

and getAttributeProjection() expects an array of selection Query objects ($selects), mapping $id/$sequence/$tenant/... to internal columns and supporting * and AS aliases.

Two follow‑ups:

  • If callers ever pass non‑SELECT queries (filters, orders, etc.) into getDocument()’s $queries parameter, those would now be misinterpreted as projected columns.
  • The naming mismatch ($queries vs $selects) and the new behaviour are not obvious from the getDocument() docblock.

To make this safer and clearer, consider:

  • Narrowing getDocument() to accept a $selects array (or pulling selects directly from QueryContext), and updating the docblock accordingly.
  • Optionally asserting that each entry in $selects has getMethod() === Query::TYPE_SELECT before using it in getAttributeProjection().

Example adjustment in the signature/docblock:

- * @param Query[] $queries
+ * @param array<Query> $selects
...
-public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document
+public function getDocument(Document $collection, string $id, array $selects = [], bool $forUpdate = false): Document

and pass $selects into getAttributeProjection().

Please confirm that all current call sites of SQL::getDocument() only ever pass SELECT queries into this parameter; if not, we should tighten the API as above.

Also applies to: 2340-2391

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c38aa72 and d5df459.

📒 Files selected for processing (2)
  • src/Database/Adapter/SQL.php (12 hunks)
  • tests/e2e/Adapter/Scopes/JoinsTests.php (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • tests/e2e/Adapter/Scopes/JoinsTests.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • tests/e2e/Adapter/Scopes/JoinsTests.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Adapter/SQL.php
🧬 Code graph analysis (1)
tests/e2e/Adapter/Scopes/JoinsTests.php (2)
src/Database/Query.php (7)
  • Query (8-1599)
  • join (1003-1006)
  • relationEqual (1041-1044)
  • equal (645-648)
  • orderAsc (810-813)
  • orderDesc (799-802)
  • select (788-791)
src/Database/Adapter/SQL.php (1)
  • getSupportForRelationships (1536-1539)
🪛 PHPMD (2.15.0)
tests/e2e/Adapter/Scopes/JoinsTests.php

79-79: Avoid unused local variables such as '$sessionNoPermissions'. (undefined)

(UnusedLocalVariable)


122-122: Avoid unused local variables such as '$session3'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Unit Test
🔇 Additional comments (6)
tests/e2e/Adapter/Scopes/JoinsTests.php (6)

1-21: LGTM!

The namespace, imports, and trait declaration are properly structured. All imported classes are used within the test methods.


165-287: Comprehensive exception testing.

Good coverage of invalid query scenarios: unknown aliases, ambiguous aliases, missing relation queries, invalid ON clause queries, and SQL injection attempts in alias names. The try/catch pattern with fail() ensures exceptions are properly thrown.


348-512: Thorough select/projection testing.

Excellent coverage of:

  • Selecting from multiple aliases (main, joined collections)
  • Attribute renaming with as parameter
  • Type preservation for float/boolean attributes
  • Internal attribute selection ($permissions, $id, $sequence, $createdAt)
  • Validation of invalid as values (SQL injection attempts, invalid as on wildcards)

592-680: Well-structured left join tests.

Good coverage of left join semantics:

  • Proper null handling for unmatched rows (lines 651, 679)
  • Skip permissions scenario with getAuthorization()->skip()
  • Additional ON clause conditions with Query::equal
  • Ordering across joined collections

682-769: Right join tests with documented edge case.

The test structure appropriately mirrors the left join tests. The comment at lines 764-766 correctly documents the edge case behavior when right join returns nulls for the main collection that are then filtered by permissions.


771-855: Multi-join and aggregation tests.

Good coverage of:

  • Chained joins across three tables with proper alias referencing
  • Error detection for undefined alias references in join chains
  • Integration of count() with join queries to verify aggregation compatibility

Copy link
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/Database/Database.php (3)

4295-4331: Align $permissions auto-select behavior between cache hits and misses in getDocument().

You correctly track whether $permissions was system-added via $permissionsAdded, and you strip it from the result on cache misses. On cache hits, you return early from the $cached branch and never apply that cleanup, so callers can see $permissions even when they didn’t select it, while the miss path hides it.

To keep behavior consistent without affecting internal permission checks, apply the same cleanup inside the $cached branch:

        if ($cached) {
            $document = $this->createDocumentInstance($collection->getId(), $cached);
@@
            $this->trigger(self::EVENT_DOCUMENT_READ, $document);
-            return $document;
+            if ($permissionsAdded) {
+                $document->removeAttribute('$permissions');
+            }
+
+            return $document;
        }
@@
-        if ($permissionsAdded) { // Or remove all queries added by system
-            $document->removeAttribute('$permissions');
-        }
+        if ($permissionsAdded) {
+            $document->removeAttribute('$permissions');
+        }

This way, the projection semantics for $permissions don’t depend on whether the document came from cache.

Also applies to: 4387-4388, 4411-4413


8977-9180: Avoid turning all errors in convertRelationshipFiltersToSubqueries() into “no matches”.

Inside the per-relationship loop you wrap the entire subquery-conversion flow in a broad try { … } catch (\Exception $e) { return null; }. Callers then interpret null as “no documents can match this relationship filter”.

That means:

  • Adapter/database errors (timeouts, connection failures),
  • Validation/logic bugs in nested find()/processNestedRelationshipPath(), and
  • Any unexpected exception

will be silently converted into an empty result set instead of surfacing as failures. This makes real outages or bugs indistinguishable from legitimate “no matches” and will be painful to debug.

I’d strongly recommend either:

  • Removing the catch and letting exceptions bubble up, or
  • Narrowing it to the specific, anticipated case you truly want to treat as “no matches” (e.g. a dedicated exception type, or at minimum a well-scoped QueryException), while letting other errors propagate.

As written, this is a significant observability/behavior change.


8086-8137: sum() doesn't handle join queries like find() and count() do.

You create a QueryContext with only the main collection and don't extract join queries. Unlike find() and count(), which call Query::getJoinQueries() and populate the context with each join's collection and alias, sum() skips this step entirely.

Any join query passed to sum() will be ignored or cause an error—join aliases won't exist in the context when queries are converted, resulting in "Unknown Alias context" exceptions.

Two options:

  • Add join support by extracting and populating joins like find()/count() do, then pass QueryContext and $joins to the adapter (requires updating the adapter's sum() signature), or
  • Detect and reject join queries up front with a clear error message if joins aren't meant to be supported yet.
src/Database/Adapter/Mongo.php (1)

1442-1469: Confirm schemaless Mongo updates should use replacement, not $set

updateDocument() always performs a $set operation:

$updateQuery = [
    '$set' => $record,
];
$this->client->update($name, $filters, $updateQuery, $options);

Per design intent for schemaless mode (getSupportForAttributes() === false), Mongo should perform replacement-style updates without $set, allowing fields omitted from the update to be removed from the stored record. This aligns with schemaless semantics where the document becomes exactly what is provided.

Always using $set means removed fields persist in the database—a regression from intended behavior. Both updateDocument() and updateDocuments() need branching:

+            if (!$this->getSupportForAttributes()) {
+                // Schemaless: full replacement
+                $this->client->update($name, $filters, $record, $options);
+            } else {
+                // Schema mode: partial update
+                $updateQuery = ['$set' => $record];
+                $this->client->update($name, $filters, $updateQuery, $options);
+            }

Apply the same fix to updateDocuments() method as well.

♻️ Duplicate comments (10)
src/Database/Database.php (2)

5818-5822: Update/upsert still skip casting(QueryContext, …), so result types can differ from create/get/find.

Right now:

  • createDocument(), createDocuments(), getDocument(), and find() all call $this->casting($context, $document, $selects) before decode().
  • updateDocument() only does $this->decode($context, $document).
  • updateDocuments() only does $this->decode($context, $doc) in the batch loop.
  • upsertDocumentsWithIncrease() only calls $this->decode($context, $doc) when !$hasOperators.

For adapters that rely on Database::casting() for scalar normalization, that means update/upsert can return documents whose scalar attributes are still in DB-native form (e.g. strings for ints/bools) while create/get/find return normalized PHP types. This is the same concern raised earlier and appears still unresolved.

Unless getSupportForCasting() semantics were intentionally inverted, consider mirroring the other CRUD paths, e.g.:

-        $context = new QueryContext();
-        $context->add($collection);
-
-        $document = $this->decode($context, $document);
+        $context = new QueryContext();
+        $context->add($collection);
+
+        $document = $this->casting($context, $document);
+        $document = $this->decode($context, $document);

and similarly call casting() before decode() in updateDocuments() and the non-operator branch of upsertDocumentsWithIncrease().

If this divergence is intentional, it’d be good to document that update/upsert may return raw DB types where other methods don’t.

Also applies to: 5974-5981, 6048-6053, 6565-6567, 6796-6800


8712-8788: Remove unused $idAdded in processRelationshipQueries() and keep $id injection relationship-only.

At the tail of processRelationshipQueries() you still assign the second element of Query::addSelect() to $idAdded but never use it, which PHPMD flags and which isn’t needed now that you always require $id only when there are relationship attributes:

if (!empty($relationships)) {
    [$queries, $idAdded] = Query::addSelect($queries, Query::select('$id', system: true));
}

You can simplify and silence the warning by only capturing the updated query list:

-        $queries = array_values($queries);
-
-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = Query::addSelect($queries, Query::select('$id', system: true));
-        }
-
-        return [$queries, $nestedSelections];
+        $queries = array_values($queries);
+
+        /**
+         * In order to populateDocumentsRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = Query::addSelect($queries, Query::select('$id', system: true));
+        }
+
+        return [$queries, $nestedSelections];

This matches the prior guidance to only auto-select $id when the collection has relationship attributes and avoids the unused local.

Based on learnings, this keeps $id auto-selection restricted to relationship-bearing collections.

src/Database/Validator/Queries/V2.php (3)

179-226: Avoid dereferencing $attribute['type'] for unknown attributes in schemaless mode

When $supportForAttributes is false and the attribute is not in $this->schema, $attribute is empty but you still access $attribute['type'] for nested checks:

$attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
if (empty($attribute) && $this->supportForAttributes) {
    throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
}

if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
    throw new \Exception('Only nested relationships allowed');
}

In schemaless mode this can trigger undefined index notices and “Only nested relationships allowed” for arbitrary nested fields, instead of behaving permissively.

Consider short‑circuiting when the attribute is unknown in schemaless mode, mirroring validateValues():

-        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
-        if (empty($attribute) && $this->supportForAttributes) {
-            throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
-        }
-
-        if (\in_array('encrypt', $attribute['filters'] ?? [])) {
+        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
+
+        if (empty($attribute)) {
+            if ($this->supportForAttributes) {
+                throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
+            }
+
+            // Schemaless: attribute not in schema, skip further attribute-based checks
+            return;
+        }
+
+        if (\in_array('encrypt', $attribute['filters'] ?? [])) {
             throw new \Exception('Cannot query encrypted attribute: ' . $attributeId);
         }
 
-        if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
+        if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
             throw new \Exception('Only nested relationships allowed');
         }

This keeps strict behavior when schema support is enabled while avoiding bogus errors and notices in schemaless mode.


526-537: Relax join-scope alias validation; current check rejects valid non-relation queries

The generic join-scope alias check:

if ($scope === 'joins') {
    if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)) {
        throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' alias reference in join has not been defined.');
    }
}

runs for every nested query under a join. For normal filter/order queries inside a join, getRightAlias() is usually an empty string, so this block incorrectly throws even when the query is otherwise valid.

You already enforce relation semantics in the TYPE_RELATION_EQUAL case (via validateAttributeExist() on both sides and isRelationExist() on the join). To avoid false positives, restrict this alias-order check to relation queries only, and make it strict:

-                if ($scope === 'joins') {
-                    if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)) {
-                        throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' alias reference in join has not been defined.');
-                    }
-                }
+                if ($scope === 'joins' && $method === Query::TYPE_RELATION_EQUAL) {
+                    if (
+                        !\in_array($query->getAlias(), $this->joinsAliasOrder, true)
+                        || !\in_array($query->getRightAlias(), $this->joinsAliasOrder, true)
+                    ) {
+                        throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' alias reference in join has not been defined.');
+                    }
+                }

This keeps the alias-order safety for relation predicates while allowing non-relation filters and orders inside joins.


666-705: Guard SELECT * against unknown aliases and missing schema entries

In the TYPE_SELECT branch, when getAttribute() === '*' you directly resolve the collection and schema:

if ($query->getAttribute() === '*') {
    $collection = $this->context->getCollectionByAlias($query->getAlias());
    $attributes = $this->schema[$collection->getId()];
    foreach ($attributes as $attribute) {
        …
    }
}

If the alias is unknown, getCollectionByAlias() can return an empty Document, so $collection->getId() is null; $this->schema[$collection->getId()] then raises an undefined index and foreach runs on null. The query effectively passes validation even though the alias context is invalid.

To make this robust and consistent with other paths (e.g. validateAttributeExist()), validate the alias and guard the schema lookup:

-                        if ($query->getAttribute() === '*') {
-                            $collection = $this->context->getCollectionByAlias($query->getAlias());
-                            $attributes = $this->schema[$collection->getId()];
+                        if ($query->getAttribute() === '*') {
+                            $collection = $this->context->getCollectionByAlias($query->getAlias());
+                            if ($collection->isEmpty()) {
+                                throw new \Exception('Invalid query: Unknown Alias context');
+                            }
+
+                            $attributes = $this->schema[$collection->getId()] ?? [];
                             foreach ($attributes as $attribute) {
                                 if (($duplications[$query->getAlias()][$attribute['$id']] ?? false) === true) {
                                     //throw new \Exception('Ambiguous column using "*" for "'.$query->getAlias().'.'.$attribute['$id'].'"');
                                 }

This ensures SELECT * fails fast for unknown aliases and avoids undefined index/foreach-on-null issues.

src/Database/Validator/Query/Filter.php (1)

1-422: Remove the commented-out legacy Filter validator (and fix Pint violation)

This file is now just a 400+ line commented Filter class plus the <?php tag, and Pint is failing with a PSR‑12 blank_line_after_opening_tag error here.

Given that V2/DVV-based validation has replaced this class, it’s better to delete the old implementation instead of keeping it commented out:

  • It removes the Pint/PSR‑12 issue.
  • Avoids confusion about which validator is active.
  • Keeps the repo smaller and easier to navigate; the old code remains in Git history if needed.

A minimal fix is to drop the entire commented block and, if you still want a placeholder, replace it with a short comment pointing to the new validator:

-<?php
-//
-//namespace Utopia\Database\Validator\Query;
-// ...
-//class Filter extends Base
-//{
-//    ...
-//}
+<?php
+
+// Legacy Filter validator has been superseded by Utopia\Database\Validator\Queries\V2.
+// This file is kept empty intentionally; see V2 for current query validation logic.

Or simply delete Filter.php entirely if no longer referenced.

src/Database/Adapter/Mongo.php (1)

1875-1887: Guard Mongo find() against unsupported orderRandom queries

In find() you iterate $orderQueries and call:

foreach ($orderQueries as $i => $order) {
    $attribute  = $order->getAttribute();
    // ...
    $direction = $order->getOrderDirection();
    // possibly flipped for cursorBefore

    $options['sort'][$attribute] = $this->getOrder($direction);
    // ...
}

Query::getOrderDirection() only supports ASC/DESC orders and throws for TYPE_ORDER_RANDOM. This adapter returns false from getSupportForOrderRandom(), so a random-order query reaching this code would currently blow up with “Invalid method: Get order direction on "orderRandom" Query”.

If you expect higher layers (Database::find() / validator) to prevent random ordering for Mongo, that’s fine; otherwise, add a defensive guard here to fail fast and clearly:

-        foreach ($orderQueries as $i => $order) {
+        foreach ($orderQueries as $i => $order) {
+            if ($order->getMethod() === Query::TYPE_ORDER_RANDOM) {
+                throw new DatabaseException('Random order is not supported by Mongo adapter');
+            }
             $attribute  = $order->getAttribute();
             // ...
             $direction = $order->getOrderDirection();

This makes the behavior explicit and avoids surprising internal exceptions if a random-order query slips through.

Also applies to: 1938-1988

src/Database/Adapter/SQL.php (2)

3076-3116: Use quoted identifiers for the RIGHT JOIN IS NULL check.

When $rightJoins is true, the permission condition appends {$alias}._uid IS NULL using unquoted identifiers. This is inconsistent with getSQLPermissionsCondition() which uses $this->quote() for identifiers.

Apply this diff to use consistent quoting:

         if (! $skipAuth) {
             $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission);
             if ($rightJoins) {
-                $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)";
+                $permissionsCondition = sprintf(
+                    '(%s OR %s.%s IS NULL)',
+                    $permissionsCondition,
+                    $this->quote($alias),
+                    $this->quote('_uid')
+                );
             }
             $where[] = $permissionsCondition;
         }

3266-3277: Same quoting inconsistency in count() for RIGHT JOIN permission check.

The count() method has the same unquoted identifier issue as find().

Apply this diff:

         if (! $skipAuth) {
             $permissionsCondition = $this->getSQLPermissionsCondition($name, $roles, $alias);
             if ($rightJoins) {
-                $permissionsCondition = "($permissionsCondition OR {$alias}._uid IS NULL)";
+                $permissionsCondition = sprintf(
+                    '(%s OR %s.%s IS NULL)',
+                    $permissionsCondition,
+                    $this->quote($alias),
+                    $this->quote('_uid')
+                );
             }
             $where[] = $permissionsCondition;
         }
src/Database/Query.php (1)

1559-1599: addSelect() does not add a select when no existing selects are present.

When $queries contains no TYPE_SELECT queries, $found remains false, and the new select query is never added. This seems counterintuitive for a method named addSelect.

If the first select should always be added, consider adjusting the logic:

-        if ($found && $merge) {
+        if ($merge) {
             $queries = [
                 ...$queries,
                 $query
             ];

             return [$queries, true];
         }

         return [$queries, false];

This preserves the duplicate/wildcard protection while allowing the first select to be added.

🧹 Nitpick comments (5)
src/Database/Database.php (2)

7992-8066: count() gains join-aware validation, but max argument semantics change.

The new count() flow (building a QueryContext, wiring joins, using DocumentsValidator, then calling adapter->count($context, …) with relationship filters converted to subqueries) looks solid.

One behavioral change: if (!is_null($max) && $max < 1) now rejects $max = 0 outright. If existing call sites ever used $max = 0 as “no limit” (similar to how sum() still documents $max = 0 as unlimited), this will start throwing DatabaseException instead of delegating to the adapter.

If backward compatibility matters, consider normalizing $max === 0 to null (or documenting the new constraint clearly) so consumers aren’t surprised by exceptions from previously-valid calls.


8645-8685: Be cautious about serializing full Query objects into cache keys.

Using \md5(\serialize($selects)) means the cache hash now depends on the entire Query objects (method, alias, system flag, etc.), not just the logical projection. That’s OK as long as Query stays a simple data holder, but if you ever add non-deterministic fields (e.g. runtime metadata) you could unintentionally fragment the cache.

If you only care about the projection, consider hashing a normalized description instead, e.g. an array of [$alias, $attribute, $as] per select.

src/Database/Adapter/Postgres.php (1)

1725-1787: Filter and quote right-hand join alias consistently

In getSQLCondition() you now support TYPE_RELATION_EQUAL:

$attributeRight = $this->quote($this->filter($query->getAttributeRight()));
$aliasRight = $this->quote($query->getRightAlias());

return "{$alias}.{$attribute}={$aliasRight}.{$attributeRight}";

The left alias goes through filter() then quote(), but the right alias is only quoted. For consistency and a bit more defense-in-depth (even though aliases are validated elsewhere), it’s better to apply the same filtering:

-            case Query::TYPE_RELATION_EQUAL:
-                $attributeRight = $this->quote($this->filter($query->getAttributeRight()));
-                $aliasRight = $this->quote($query->getRightAlias());
-
-                return "{$alias}.{$attribute}={$aliasRight}.{$attributeRight}";
+            case Query::TYPE_RELATION_EQUAL:
+                $attributeRight = $this->quote($this->filter($query->getAttributeRight()));
+                $aliasRight = $this->quote($this->filter($query->getRightAlias()));
+
+                return "{$alias}.{$attribute} = {$aliasRight}.{$attributeRight}";

Keeps both sides of the relation predicate normalized the same way as other SQL fragments.

src/Database/Adapter/Mongo.php (1)

1875-1887: Silence PHPMD “unused parameter” warnings for $joins / $vectors

Both find() and count() accept $joins/$vectors to match the adapter interface, but Mongo doesn’t support relationships or vectors and the parameters are unused:

public function find(
    QueryContext $context,
    ?int $limit = 25,
    ?int $offset = null,
    array $cursor = [],
    string $cursorDirection = Database::CURSOR_AFTER,
    string $forPermission = Database::PERMISSION_READ,
    array $selects = [],
    array $filters = [],
    array $joins = [],
    array $vectors = [],
    array $orderQueries = []
): array { … }

public function count(QueryContext $context, ?int $max, array $filters = [], array $joins = []): int { … }

PHPMD flags these as unused. A simple, non-behavioral way to clean this up is to explicitly unset them at the start of each method:

 public function find(
     QueryContext $context,
@@
-    array $joins = [],
-    array $vectors = [],
+    array $joins = [],
+    array $vectors = [],
     array $orderQueries = []
 ): array {
+    unset($joins, $vectors);
@@
 public function count(QueryContext $context, ?int $max, array $filters = [], array $joins = []): int
 {
+    unset($joins);

This keeps the signature stable while satisfying static analysis.

Also applies to: 2140-2141

src/Database/Query.php (1)

338-366: Consider returning the direction constant from the class instead of Database.

The getCursorDirection() and getOrderDirection() methods throw generic \Exception instead of QueryException. For consistency with other query-related errors, consider using QueryException.

-        throw new \Exception('Invalid method: Get cursor direction on "'.$this->method.'" Query');
+        throw new QueryException('Invalid method: Get cursor direction on "'.$this->method.'" Query');
-        throw new \Exception('Invalid method: Get order direction on "'.$this->method.'" Query');
+        throw new QueryException('Invalid method: Get order direction on "'.$this->method.'" Query');
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d5df459 and bb651d6.

📒 Files selected for processing (8)
  • src/Database/Adapter/Mongo.php (14 hunks)
  • src/Database/Adapter/Postgres.php (2 hunks)
  • src/Database/Adapter/SQL.php (12 hunks)
  • src/Database/Database.php (55 hunks)
  • src/Database/Query.php (27 hunks)
  • src/Database/QueryContext.php (1 hunks)
  • src/Database/Validator/Queries/V2.php (1 hunks)
  • src/Database/Validator/Query/Filter.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/Database/QueryContext.php
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Query.php
  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
  • src/Database/Adapter/SQL.php
📚 Learning: 2025-08-14T06:35:30.429Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 661
File: tests/e2e/Adapter/Scopes/SpatialTests.php:180-186
Timestamp: 2025-08-14T06:35:30.429Z
Learning: Query::distance method in Utopia\Database\Query expects an array of values parameter, where each value is a [geometry, distance] pair. So the correct format is Query::distance('attribute', [[[lat, lng], distance]]) where the outer array contains the values and each value is [geometry, distance].

Applied to files:

  • src/Database/Query.php
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
  • src/Database/Adapter/Mongo.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-10-29T12:27:57.071Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 747
File: src/Database/Adapter/Mongo.php:1449-1453
Timestamp: 2025-10-29T12:27:57.071Z
Learning: In src/Database/Adapter/Mongo.php, when getSupportForAttributes() returns false (schemaless mode), the updateDocument method intentionally uses a raw document without $set operator for replacement-style updates, as confirmed by the repository maintainer ArnabChatterjee20k.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Adapter/Mongo.php
🧬 Code graph analysis (3)
src/Database/Adapter/Postgres.php (2)
src/Database/Query.php (6)
  • setAttributeRight (331-336)
  • getAttributeRight (287-290)
  • getAttribute (255-258)
  • getAlias (277-280)
  • Query (8-1600)
  • getRightAlias (282-285)
src/Database/Adapter.php (1)
  • quote (491-491)
src/Database/Validator/Queries/V2.php (9)
src/Database/Exception.php (1)
  • Exception (7-21)
src/Database/Query.php (8)
  • Query (8-1600)
  • __construct (198-233)
  • isNested (1301-1308)
  • getMethod (247-250)
  • getFilterQueries (1183-1186)
  • isSpatialQuery (456-473)
  • isVectorQuery (479-482)
  • parse (491-504)
src/Database/QueryContext.php (4)
  • QueryContext (8-95)
  • __construct (25-27)
  • getCollections (32-35)
  • getCollectionByAlias (45-63)
src/Database/Validator/Alias.php (5)
  • Alias (7-70)
  • getDescription (15-18)
  • isArray (54-57)
  • getType (66-69)
  • isValid (26-45)
src/Database/Validator/AsQuery.php (6)
  • AsQuery (7-84)
  • __construct (14-18)
  • getDescription (24-27)
  • isArray (68-71)
  • getType (80-83)
  • isValid (35-59)
src/Database/Validator/Datetime.php (1)
  • Datetime (7-152)
src/Database/Validator/Query/Cursor.php (1)
  • Cursor (10-56)
src/Database/Validator/Query/Limit.php (1)
  • Limit (9-63)
src/Database/Validator/Query/Offset.php (3)
  • Offset (9-59)
  • __construct (16-19)
  • isValid (25-53)
src/Database/Adapter/Mongo.php (2)
src/Database/QueryContext.php (3)
  • QueryContext (8-95)
  • getMainCollection (40-43)
  • skipAuth (83-94)
src/Database/Document.php (2)
  • removeAttribute (287-293)
  • Document (12-470)
🪛 GitHub Actions: Linter
src/Database/Validator/Query/Filter.php

[error] 1-1: Pint lint failed. PSR-12 violation: 'blank_line_after_opening_tag' in src/Database/Validator/Query/Filter.php.

🪛 PHPMD (2.15.0)
src/Database/Database.php

8784-8784: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

src/Database/Adapter/Mongo.php

1884-1884: Avoid unused parameters such as '$joins'. (undefined)

(UnusedFormalParameter)


1885-1885: Avoid unused parameters such as '$vectors'. (undefined)

(UnusedFormalParameter)


2140-2140: Avoid unused parameters such as '$joins'. (undefined)

(UnusedFormalParameter)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Unit Test
🔇 Additional comments (25)
src/Database/Database.php (6)

5118-5123: QueryContext-based casting/decoding on create/createDocuments looks consistent.

Creating a QueryContext for the collection and routing fresh documents through casting($context, …) and decode($context, …) after adapter->castingAfter() mirrors the find()/getDocument() behavior and should keep type normalization uniform across read/write paths. I don’t see issues in these additions.

Also applies to: 5171-5173, 5234-5238


4988-5013: applySelectFiltersToDocuments()’s projection logic looks sound for post-batch relationship filtering.

Collecting attributesToKeep from select queries, special-casing '*', and always preserving internal ($*) attributes gives you a clean way to trim batch-fetched relationship documents back down to the requested projection. Given this is only invoked with relationship-level select queries, the behavior here matches the intent.


7496-7551: Bulk delete path’s new QueryContext/validation and required-attribute guard look consistent.

deleteDocuments() now:

  • Builds a QueryContext, wires all join collections into it, and records per-collection skipAuth flags (via addSkipAuth), just like find().
  • Validates the original queries with DocumentsValidator.
  • Ensures $permissions and $sequence are present when an explicit projection is used, throwing a QueryException if the select list is insufficient for the internal delete flow.

That matches what the adapter and permission cleanup need and should fail fast instead of producing partial/deceptive behavior. No functional issues spotted here.

Also applies to: 7581-7588


7750-7771: Join-aware find() integration with QueryContext looks coherent.

Seeding QueryContext with the main collection plus all join targets, computing per-collection skipAuth flags, validating with DocumentsValidator, then running $this->convertQueries($context, $queries) before passing selects, filters, joins, vectors, and orderQueries down to the adapter gives a clear and consistent story for join handling. Authorization, context, and projection all line up with the new model.

Also applies to: 7795-7806, 7841-7865, 7874-7889


6090-6466: Targeted Query::select('$id') usages in relationship helpers are a good fit for the new constraints.

Switching internal getDocument / findOne / find calls in updateDocumentRelationships(), deleteRestrict(), deleteSetNull(), and deleteCascade() to include explicit Query::select('$id') (and sometimes only $id) is a nice refinement:

  • It respects the new rule that getDocument()’s $queries argument must be composed solely of select queries.
  • It keeps payloads minimal while still providing the only attribute these helpers actually need for existence/uniqueness checks and cascading behavior.

I don’t see regressions introduced by these changes.

Also applies to: 7203-7375, 7395-7477


8547-8561: convertQueries() / convertQuery() with QueryContext give consistent alias-aware typing.

The new convertQueries(QueryContext $context, array $queries) recursion and the alias-based lookup in convertQuery() (with a clear QueryException on unknown aliases) provide a clean, centralized place to attach attribute metadata (array flag, type, datetime conversion) across joins and nested queries. Skipping TYPE_SELECT here also avoids mutating simple projections. This looks correct and nicely factored.

Also applies to: 8566-8615

src/Database/Adapter/SQL.php (7)

20-20: LGTM!

The import of QueryContext is correctly added to support the new context-driven query API.


375-378: LGTM!

The getDocument method correctly uses the new getAttributeProjection signature and properly quotes the alias in the WHERE clause.


1800-1805: LGTM!

The getSQLOperator correctly maps join type constants to their SQL equivalents. Note that TYPE_INNER_JOIN maps to 'JOIN' (which is semantically equivalent to INNER JOIN).


2306-2334: LGTM!

The getTenantQuery signature is correctly extended with the forceIsNull parameter to support RIGHT JOIN scenarios where NULL tenant values should be allowed in the WHERE clause.


2344-2381: LGTM!

The rewritten getAttributeProjection correctly:

  • Returns quoted default alias with .* when no selects are provided
  • Maps special attributes ($id, $collection, etc.) to internal keys
  • Filters and quotes attribute names properly
  • Handles the AS aliasing for selected columns

2981-3211: The find() method implementation with QueryContext is well-structured.

The method correctly:

  • Extracts collection from context
  • Handles join permissions via context->skipAuth()
  • Builds join SQL with proper operator mapping from getSQLOperator()
  • Tracks RIGHT JOINs for permission adjustments
  • Handles cursor pagination with proper tie-breaking logic
  • Includes vector distance ordering

3225-3320: The count() method implementation with QueryContext looks correct.

The method properly:

  • Uses QueryContext for collection and skip-auth checks
  • Handles joins with proper permission logic
  • Tracks RIGHT JOINs for NULL handling
  • Uses a subquery approach with LIMIT support
src/Database/Query.php (12)

44-46: LGTM!

The new join-related constants are properly defined and organized:

  • TYPE_RELATION_EQUAL for join condition queries
  • TYPE_INNER_JOIN, TYPE_LEFT_JOIN, TYPE_RIGHT_JOIN for join operations

Also applies to: 69-73


133-173: LGTM!

The new JOINS_TYPES and FILTER_TYPES arrays are well-organized and include all relevant query types. TYPE_RELATION_EQUAL is correctly included in FILTER_TYPES since it's used in join ON conditions.


176-183: LGTM!

The new metadata properties and protected constructor correctly:

  • Add fields for join metadata (collection, alias, aliasRight, attributeRight, as, system)
  • Default alias and aliasRight to Query::DEFAULT_ALIAS when empty
  • Wire all fields through the constructor

Also applies to: 198-233


277-300: LGTM!

The new getters are straightforward and provide proper access to the metadata fields.


441-449: LGTM!

The isMethod() function correctly includes the new join and relation types, allowing them to be recognized as valid query methods for parsing and validation.


475-482: LGTM!

The new isVectorQuery() static helper method provides a clean way to check if a method is a vector query type.


517-555: LGTM!

The parseQuery() method correctly:

  • Extracts the new metadata fields from the input array
  • Handles nested queries for both logical types AND join types
  • Passes all metadata fields to the constructor using named parameters

588-620: LGTM!

The toArray() method correctly:

  • Emits new metadata fields only when they differ from defaults
  • Handles nested serialization for both logical and join queries via isJoin()

997-1044: LGTM!

The join factory methods are well-designed:

  • join() and innerJoin() are aliases for inner join
  • leftJoin() and rightJoin() create appropriate join types
  • relationEqual() creates join ON conditions with proper alias tracking

1066-1195: LGTM!

The new public helper methods (getSelectQueries, getJoinQueries, getLimitQueries, getLimitQuery, getOffsetQueries, getOffsetQuery, getOrderQueries, getCursorQueries, getFilterQueries, getVectorQueries) provide a clean public API that replaces direct getByType() calls.


1310-1321: LGTM!

The isJoin() and isFilter() methods provide clean type-checking utilities for query categorization.


1053-1064: No breaking change — getByType() is an internal implementation detail.

The method is not tested, not documented, and has no usages within the repository. Changing its visibility from public to protected aligns with its actual role as an internal helper method rather than part of the public API surface.

Likely an incorrect or invalid review comment.

Copy link
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Database/Adapter/SQL.php (1)

2965-3074: Fix cursor pagination conditions to use each order query’s own alias

In find(), the cursor WHERE clauses for multi‑column ordering currently use the current order’s alias for all previous attributes:

foreach ($orderQueries as $i => $order) {
    $orderAlias = $order->getAlias();
    …
    for ($j = 0; $j < $i; $j++) {
        $prevQuery = $orderQueries[$j];
        $prevOriginal = $prevQuery->getAttribute();
        $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal));
        …
        $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}";
    }
    …
    $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}";
}

If different order clauses use different aliases (e.g. main vs join alias), this generates equality predicates against the wrong table alias and can yield incorrect filters or invalid SQL.

Use each previous query’s alias instead:

-            for ($j = 0; $j < $i; $j++) {
-                $prevQuery = $orderQueries[$j];
-                $prevOriginal = $prevQuery->getAttribute();
-                $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal));
-
-                $bindName = ":cursor_{$j}";
-                $binds[$bindName] = $cursor[$prevOriginal];
-
-                $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}";
-            }
+            for ($j = 0; $j < $i; $j++) {
+                $prevQuery    = $orderQueries[$j];
+                $prevAlias    = $prevQuery->getAlias();
+                $prevOriginal = $prevQuery->getAttribute();
+                $prevAttr     = $this->filter($this->getInternalKeyForAttribute($prevOriginal));
+
+                $bindName = ":cursor_{$j}";
+                $binds[$bindName] = $cursor[$prevOriginal];
+
+                $conditions[] = "{$this->quote($prevAlias)}.{$this->quote($prevAttr)} = {$bindName}";
+            }

The comparison for the current attribute can continue to use $orderAlias as it already does. This keeps cursor pagination correct when ordering across fields from different aliases.

src/Database/Database.php (1)

4295-4335: System $permissions select is stripped only on non‑cached reads, causing inconsistent results.

In getDocument() you compute $permissionsAdded via Query::addSelect() and later remove $permissions only in the non‑cache branch (Lines 4411–4413). When the same document is served from cache, $permissions remains present even when it was added implicitly, so callers see different shapes depending on cache hit vs miss.

Consider also stripping $permissions in the cache‑hit branch, after auth checks and before returning:

if ($cached) {
    $document = $this->createDocumentInstance($collection->getId(), $cached);

    if ($collection->getId() !== self::METADATA) {
        if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, [
            ...$collection->getRead(),
            ...($documentSecurity ? $document->getRead() : [])
        ]))) {
            return $this->createDocumentInstance($collection->getId(), []);
        }
    }

+    if ($permissionsAdded) {
+        $document->removeAttribute('$permissions');
+    }
+
    $this->trigger(self::EVENT_DOCUMENT_READ, $document);

    return $document;
}

Also applies to: 4401-4413

♻️ Duplicate comments (4)
src/Database/Validator/Query/Filter.php (1)

2-422: Delete the commented Filter implementation (or replace with a minimal stub) and fix PSR‑12 blank-line error

The entire legacy Filter validator is kept as commented code and line 1 violates PSR-12: blank_line_after_opening_tag. This both clutters the repo and currently breaks the linter.

Recommend either:

  • Remove this file entirely if Filter is deprecated in favor of the V2/Documents validator, or
  • Replace the block with a short comment explaining that Utopia\Database\Validator\Query\Filter was removed in favor of Utopia\Database\Validator\Queries\V2 (DocumentsValidator), and ensure there is a blank line after <?php>.

In either case, drop the 400+ lines of commented implementation.

src/Database/Validator/Queries/V2.php (1)

176-226: Short‑circuit validateAttributeExist() for unknown attributes in schemaless mode

When $supportForAttributes is false and an attribute is not in $this->schema, $attribute is empty but the method still executes type-based checks:

$attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
if (empty($attribute) && $this->supportForAttributes) {
    throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
}

if (\in_array('encrypt', $attribute['filters'] ?? [])) { ... }
if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) { ... }

In schemaless mode this both:

  • Accesses $attribute['type'] on an empty array, and
  • Incorrectly throws “Only nested relationships allowed” for arbitrary nested fields, instead of behaving permissively as in validateValues().

Align this with validateValues() by returning early when the attribute is unknown and schema support is disabled:

-        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
-        if (empty($attribute) && $this->supportForAttributes) {
-            throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
-        }
+        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
+
+        if (empty($attribute)) {
+            if ($this->supportForAttributes) {
+                throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
+            }
+
+            // Schemaless: unknown attribute, skip attribute-based checks
+            return;
+        }

This avoids undefined-index notices and keeps schemaless behavior consistent with the rest of the validator.

src/Database/Adapter/Mongo.php (1)

1938-1950: Guard against unsupported ORDER_RANDOM before calling getOrderDirection()

This issue was flagged in a previous review but remains unaddressed. At line 1944, $order->getOrderDirection() is called without checking for TYPE_ORDER_RANDOM. Since this adapter reports getSupportForOrderRandom() === false and getOrderDirection() throws an exception for random-order queries, a defensive check is needed.

Apply this diff to add the guard:

         foreach ($orderQueries as $i => $order) {
             $attribute  = $order->getAttribute();
             $originalAttribute = $attribute;
             $attribute = $this->getInternalKeyForAttribute($originalAttribute);
             $attribute = $this->filter($attribute);
 
+            // Reject unsupported random order queries
+            if ($order->getMethod() === Query::TYPE_ORDER_RANDOM) {
+                throw new DatabaseException('MongoDB adapter does not support random ordering');
+            }
+
             $direction = $order->getOrderDirection();
src/Database/Query.php (1)

1560-1599: addSelect still never appends when there are no existing TYPE_SELECT queries

The logic here remains the same as in the earlier review: because the append is guarded by if ($found && $merge), calling addSelect() on a list that contains no TYPE_SELECT queries will never add the new select — $found stays false, so you always return the original $queries unchanged.

That means the first select is silently dropped, which is surprising for a method named addSelect and can break callers that rely on it to inject selects into an initially empty query list.

You can preserve the duplicate/wildcard protection while always allowing the first select to be appended by dropping the $found condition, e.g.:

-        if ($found && $merge) {
+        if ($merge) {
             $queries = [
                 ...$queries,
                 $query
             ];

             return [$queries, true];
         }

This still prevents merging when there is a conflicting * or identical select, but no longer blocks the initial select from being added.

🧹 Nitpick comments (5)
src/Database/Adapter/SQL.php (1)

374-379: Confirm getDocument() always passes only SELECT queries into getAttributeProjection()

getDocument() now builds its projection via:

SELECT {$this->getAttributeProjection($queries)}
FROMWHERE {$this->quote($alias)}._uid = :_uid

getAttributeProjection() assumes its input is a list of TYPE_SELECT queries. If callers ever pass a mixed $queries array (filters, limits, etc.), those will be treated as selects and end up in the projection list.

If the surrounding codepath guarantees $queries is already filtered down to selects (e.g. via Query::getSelectQueries()), this is fine; otherwise consider either:

  • Passing only the select subset into getAttributeProjection(), or
  • Filtering $selects inside getAttributeProjection() to getMethod() === Query::TYPE_SELECT.

Tagging for verification rather than as a bug, since the upstream contract isn’t visible here.

src/Database/Adapter/Mongo.php (2)

1875-1887: Document or validate unsupported features in find() signature

The $joins and $vectors parameters are unused since this adapter reports getSupportForRelationships() === false and getSupportForVectors() === false. While these parameters maintain interface consistency, consider adding:

  1. PHPDoc annotations like @param array<Query> $joins (unused - MongoDB does not support joins)
  2. Defensive validation at the start of the method to throw a clear exception if non-empty arrays are passed

This would improve maintainability and prevent silent failures if these parameters are mistakenly populated.

Apply this diff to add defensive checks:

     public function find(
         QueryContext $context,
         ?int $limit = 25,
         ?int $offset = null,
         array $cursor = [],
         string $cursorDirection = Database::CURSOR_AFTER,
         string $forPermission = Database::PERMISSION_READ,
         array $selects = [],
         array $filters = [],
         array $joins = [],
         array $vectors = [],
         array $orderQueries = []
     ): array {
+        if (!empty($joins)) {
+            throw new DatabaseException('MongoDB adapter does not support joins');
+        }
+        if (!empty($vectors)) {
+            throw new DatabaseException('MongoDB adapter does not support vector queries');
+        }
+
         $collection = $context->getMainCollection();

2140-2162: Unused $joins parameter in count() signature

Similar to find(), the $joins parameter at line 2140 is unused since MongoDB doesn't support joins (getSupportForRelationships() === false). For interface consistency this is acceptable, but consider adding PHPDoc annotation or a defensive check as suggested for the find() method.

The count implementation correctly uses QueryContext for collection retrieval and authorization, and properly handles the aggregation pipeline with timeout support.

src/Database/Query.php (2)

1212-1281: Confirm whether join queries should be included in the filters bucket of groupByType

groupByType()’s switch still routes everything that is not order/limit/offset/cursor/select into the 'filters' bucket. With the addition of join methods (TYPE_INNER_JOIN, TYPE_LEFT_JOIN, TYPE_RIGHT_JOIN) and the new isJoin() helper, join queries will currently fall into 'filters' here.

If groupByType() is still used in places that now support joins, you may want to either:

  • Explicitly skip join methods in this switch, or
  • Add a dedicated 'joins' bucket alongside 'filters' and 'selections'.

If groupByType() is only used in legacy, non-join code paths, this may be fine as-is, but it’s worth double-checking call sites to ensure joins are not accidentally treated as regular filters.


338-366: Consider throwing a domain-specific exception in getCursorDirection / getOrderDirection

The new helpers correctly map cursor and order query methods to Database direction constants, but on misuse they throw a generic \Exception. For consistency with the rest of this class, consider throwing a more specific exception (e.g. QueryException or \InvalidArgumentException) so callers can distinguish programming mistakes from other failures more easily. This is non-critical but would tighten the API surface.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d5df459 and 0359bb7.

📒 Files selected for processing (8)
  • src/Database/Adapter/Mongo.php (14 hunks)
  • src/Database/Adapter/Postgres.php (2 hunks)
  • src/Database/Adapter/SQL.php (12 hunks)
  • src/Database/Database.php (55 hunks)
  • src/Database/Query.php (27 hunks)
  • src/Database/QueryContext.php (1 hunks)
  • src/Database/Validator/Queries/V2.php (1 hunks)
  • src/Database/Validator/Query/Filter.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/Database/Adapter/Postgres.php
  • src/Database/QueryContext.php
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Database.php
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Adapter/Mongo.php
  • src/Database/Database.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Adapter/SQL.php
  • src/Database/Query.php
  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Database.php
📚 Learning: 2025-10-29T12:27:57.071Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 747
File: src/Database/Adapter/Mongo.php:1449-1453
Timestamp: 2025-10-29T12:27:57.071Z
Learning: In src/Database/Adapter/Mongo.php, when getSupportForAttributes() returns false (schemaless mode), the updateDocument method intentionally uses a raw document without $set operator for replacement-style updates, as confirmed by the repository maintainer ArnabChatterjee20k.

Applied to files:

  • src/Database/Validator/Queries/V2.php
  • src/Database/Adapter/Mongo.php
📚 Learning: 2025-08-14T06:35:30.429Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 661
File: tests/e2e/Adapter/Scopes/SpatialTests.php:180-186
Timestamp: 2025-08-14T06:35:30.429Z
Learning: Query::distance method in Utopia\Database\Query expects an array of values parameter, where each value is a [geometry, distance] pair. So the correct format is Query::distance('attribute', [[[lat, lng], distance]]) where the outer array contains the values and each value is [geometry, distance].

Applied to files:

  • src/Database/Query.php
🧬 Code graph analysis (5)
src/Database/Validator/Queries/V2.php (9)
src/Database/Document.php (3)
  • Document (12-470)
  • getId (63-66)
  • getArrayCopy (423-458)
src/Database/Query.php (12)
  • Query (8-1600)
  • __construct (198-233)
  • getAttribute (255-258)
  • isNested (1301-1308)
  • getAlias (277-280)
  • getMethod (247-250)
  • getFilterQueries (1183-1186)
  • getValues (263-266)
  • isSpatialQuery (456-473)
  • isVectorQuery (479-482)
  • parse (491-504)
  • getAs (292-295)
src/Database/QueryContext.php (4)
  • QueryContext (8-95)
  • __construct (25-27)
  • getCollections (32-35)
  • getCollectionByAlias (45-63)
src/Database/Validator/Alias.php (5)
  • Alias (7-70)
  • getDescription (15-18)
  • isArray (54-57)
  • getType (66-69)
  • isValid (26-45)
src/Database/Validator/AsQuery.php (6)
  • AsQuery (7-84)
  • __construct (14-18)
  • getDescription (24-27)
  • isArray (68-71)
  • getType (80-83)
  • isValid (35-59)
src/Database/Validator/Datetime.php (1)
  • Datetime (7-152)
src/Database/Validator/Query/Cursor.php (1)
  • Cursor (10-56)
src/Database/Validator/Query/Limit.php (1)
  • Limit (9-63)
src/Database/Adapter/SQL.php (2)
  • __construct (60-63)
  • count (3225-3320)
src/Database/Adapter/SQL.php (3)
src/Database/QueryContext.php (3)
  • QueryContext (8-95)
  • getMainCollection (40-43)
  • skipAuth (83-94)
src/Database/Query.php (10)
  • Query (8-1600)
  • select (788-791)
  • getAttribute (255-258)
  • getAlias (277-280)
  • getAs (292-295)
  • getOrderDirection (351-366)
  • join (1003-1006)
  • getCollection (297-300)
  • getMethod (247-250)
  • getValues (263-266)
src/Database/Validator/Authorization.php (1)
  • getRoles (105-108)
src/Database/Query.php (2)
src/Database/Database.php (2)
  • __construct (447-676)
  • Database (38-9411)
src/Database/QueryContext.php (1)
  • __construct (25-27)
src/Database/Adapter/Mongo.php (3)
src/Database/QueryContext.php (3)
  • QueryContext (8-95)
  • getMainCollection (40-43)
  • skipAuth (83-94)
src/Database/Adapter/SQL.php (1)
  • renameAttribute (310-327)
src/Database/Validator/Authorization.php (1)
  • getRoles (105-108)
src/Database/Database.php (4)
src/Database/Validator/Queries/V2.php (2)
  • V2 (23-716)
  • getDescription (135-138)
src/Database/QueryContext.php (7)
  • QueryContext (8-95)
  • add (68-76)
  • getCollections (32-35)
  • skipAuth (83-94)
  • addSkipAuth (78-81)
  • getMainCollection (40-43)
  • getCollectionByAlias (45-63)
src/Database/Query.php (12)
  • Query (8-1600)
  • getSelectQueries (1070-1075)
  • addSelect (1566-1599)
  • select (788-791)
  • getAttribute (255-258)
  • join (1003-1006)
  • getCollection (297-300)
  • limit (831-834)
  • offset (842-845)
  • setAttribute (321-326)
  • isNested (1301-1308)
  • isJoin (1313-1316)
src/Database/Document.php (4)
  • getAttribute (224-231)
  • Document (12-470)
  • getCollection (85-88)
  • setAttribute (244-261)
🪛 GitHub Actions: Linter
src/Database/Validator/Query/Filter.php

[error] 1-1: PSR-12: blank_line_after_opening_tag

🪛 PHPMD (2.15.0)
src/Database/Validator/Queries/V2.php

513-513: Avoid unused local variables such as '$ambiguous'. (undefined)

(UnusedLocalVariable)


514-514: Avoid unused local variables such as '$duplications'. (undefined)

(UnusedLocalVariable)

src/Database/Adapter/Mongo.php

1884-1884: Avoid unused parameters such as '$joins'. (undefined)

(UnusedFormalParameter)


1885-1885: Avoid unused parameters such as '$vectors'. (undefined)

(UnusedFormalParameter)


2140-2140: Avoid unused parameters such as '$joins'. (undefined)

(UnusedFormalParameter)

src/Database/Database.php

8784-8784: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

🔇 Additional comments (8)
src/Database/Adapter/SQL.php (4)

1770-1805: Join type mapping in getSQLOperator() looks correct

The new cases for TYPE_INNER_JOIN, TYPE_LEFT_JOIN, and TYPE_RIGHT_JOIN returning JOIN/LEFT JOIN/RIGHT JOIN are a clean way to centralize join keyword selection and are correctly reused from the join-building code in find()/count().


2302-2335: Extended getTenantQuery() contract is consistent with new right‑join handling

Adding $condition and $forceIsNull and then using forceIsNull when any right join is present:

$orIsNull = '';
if ($collection === Database::METADATA || $forceIsNull) {
    $orIsNull = " OR {$alias}{$dot}_tenant IS NULL";
}
…
return "{$condition} ({$alias}{$dot}_tenant IN ({$bindings}) {$orIsNull})";

matches the updated find()/count() calls and keeps existing call sites (e.g. permissions subqueries) working via the default 'AND'. The empty condition: '' pattern for top‑level $where[] entries is also consistent with how sum() already uses this helper.


3147-3210: Result fetching and mapping in find() looks solid after context/join refactor

The new find() implementation:

  • Uses getAttributeProjection($selects) consistently for projections.
  • Correctly merges vector-based ORDER BY expressions ahead of normal orders.
  • Binds floats via getFloatPrecision() and PDO::PARAM_STR, leaving other types to getPDOType().
  • Maps internal columns (_uid, _id, _tenant, _createdAt, _updatedAt, _permissions) back to their $… counterparts before wrapping each row in Document, and reverses results when using CURSOR_BEFORE.

This aligns well with the QueryContext-driven API and looks coherent.


3216-3320: count()’s QueryContext/joins integration matches find() semantics

The updated count() mirrors the new find() behavior:

  • Accepts QueryContext and array<Query> $joins.
  • Builds $sqlJoin using getSQLOperator($join->getMethod()) and getSQLConditions() for ON clauses.
  • Applies per-join permission filters and tenant conditions, and tracks right joins to relax main‑alias permissions (OR alias._uid IS NULL) and tenant filters via forceIsNull.
  • Applies an optional $max via inner LIMIT :limit.

This keeps count semantics aligned with the data returned by find(), including joins and right‑join behavior.

src/Database/Adapter/Mongo.php (4)

19-19: LGTM: QueryContext integration and renameAttribute signature update

The import of QueryContext and the updated renameAttribute signature align with the context-driven architecture changes. The internal key mapping is correctly applied to both the old and new attribute names.

Also applies to: 711-722


2565-2598: LGTM: getAttributeProjection implementation

The projection mapping is correctly implemented:

  • Returns empty array when no selects or wildcard (*) is present (MongoDB returns all fields)
  • Properly maps internal attributes ($id_uid, $sequence_id, etc.)
  • Skips unsupported $collection attribute
  • Returns MongoDB projection format ({attribute: 1})

This aligns with the conditional $sequence removal logic in find() and getDocument() where MongoDB's always-returned _id field is cleaned up when not requested.


1790-1792: LGTM: Updated deleteDocuments to use Query::equal

The change from direct filter construction to using Query::equal('$sequence', $sequences) aligns with the new query-building pattern and ensures consistent internal key mapping through buildFilters().


1125-1136: No issue found. The Database.php wrapper (lines 4275-4289) explicitly validates and filters queries before passing them to the Mongo adapter. It extracts SELECT queries via Query::getSelectQueries($queries) and throws a QueryException if any non-SELECT queries are present. Consequently, getAttributeProjection() is guaranteed to receive only SELECT Query objects, making the filtering unnecessary at the adapter level.

Likely an incorrect or invalid review comment.

Copy link
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Database.php (1)

8807-8994: Many‑to‑many relationship filters: skipRelationships likely breaks parent ID derivation.

  • processNestedRelationshipPath() and convertRelationshipFiltersToSubqueries() rely on:
    • Calling find() on the related collection,
    • Selecting both '$id' and the relationship’s twoWayKey,
    • Then deriving parent IDs from the twoWayKey attribute on those related documents.
  • For many‑to‑many relationships in this codebase, those reverse links are populated via relationship resolution (populateDocumentsRelationships), not as plain scalar columns.
  • Both in processNestedRelationshipPath() and in the direct needsParentResolution branch of convertRelationshipFiltersToSubqueries(), the inner find() calls are wrapped in skipRelationships(...), which disables relationship population; this contradicts the earlier, working pattern for many‑to‑many filtering that depended on not skipping relationships to materialize the two‑way keys. Based on the prior learning, that will tend to produce empty $parentIds and cause such filters to short‑circuit to “no matches” instead of returning the correct parent documents.

I’d suggest:

  • For relationType === RELATION_MANY_TO_MANY, drop the skipRelationships() wrapper when fetching the related collection used to derive parent IDs (and ensure the necessary selects are present), or
  • Factor out a dedicated many‑to‑many branch that reuses the earlier, proven approach for resolving parent IDs from populated relationship attributes instead of from raw columns.

Based on learnings, many‑to‑many filtering previously relied on find() without skipRelationships to populate the reverse relationship before deriving parent IDs.

♻️ Duplicate comments (8)
src/Database/Validator/Query/Filter.php (1)

3-423: The commented-out Filter validator class should be removed.

This concern has already been raised in a previous review. The entire 423-line Filter validator class remains commented out. As noted in the prior review, if this validator has been superseded by the V2 validation approach (DocumentsValidator with QueryContext), the commented code should be deleted entirely rather than kept as a non-functional placeholder.

src/Database/Query.php (2)

515-555: Validate new metadata fields in parseQuery() to avoid raw TypeErrors

parseQuery() now extracts additional metadata:

$method         = $query['method'] ?? '';
$attribute      = $query['attribute'] ?? '';
$attributeRight = $query['attributeRight'] ?? '';
$values         = $query['values'] ?? [];
$alias          = $query['alias'] ?? '';
$aliasRight     = $query['aliasRight'] ?? '';
$as             = $query['as'] ?? '';
$collection     = $query['collection'] ?? '';

You validate only method, attribute, and values types before calling the constructor. If any of the new fields is not a string (e.g. alias: [] in user JSON), the strict-typed constructor will throw a TypeError instead of the expected QueryException, which is inconsistent with the rest of the parsing API.

Consider mirroring the existing validation for these keys, for example:

-        if (!\is_array($values)) {
+        if (!\is_array($values)) {
             throw new QueryException('Invalid query values. Must be an array, got ' . \gettype($values));
         }
+
+        foreach (['alias' => $alias, 'aliasRight' => $aliasRight, 'attributeRight' => $attributeRight, 'collection' => $collection, 'as' => $as] as $key => $meta) {
+            if (!\is_string($meta)) {
+                throw new QueryException('Invalid query ' . $key . '. Must be a string, got ' . \gettype($meta));
+            }
+        }

This keeps parse()/parseQuery() failures predictable and contained to QueryException even for malformed JSON.


76-120: Confirm whether join/relation types should be exposed via TYPES

isMethod() has been updated to recognize join and relation-equal types:

case self::TYPE_RELATION_EQUAL,
     self::TYPE_INNER_JOIN,
     self::TYPE_LEFT_JOIN,
     self::TYPE_RIGHT_JOIN,
     self::TYPE_VECTOR_DOT,
     ...

but the public TYPES constant still omits these:

public const TYPES = [
    self::TYPE_EQUAL,
    ...
    self::TYPE_CURSOR_BEFORE,
    self::TYPE_AND,
    self::TYPE_OR,
];

If external consumers (or internal validators) treat Query::TYPES as the canonical list of supported methods, joins and relationEqual will be invisible there despite being accepted by isMethod()/parse(). That asymmetry can be surprising.

If joins are intended as first-class public query methods, consider appending them to TYPES:

     public const TYPES = [
         ...
         self::TYPE_AND,
         self::TYPE_OR,
+        self::TYPE_RELATION_EQUAL,
+        self::TYPE_INNER_JOIN,
+        self::TYPE_LEFT_JOIN,
+        self::TYPE_RIGHT_JOIN,
     ];

If instead joins are meant to be internal-only, a short docblock on TYPES explaining that it intentionally excludes join methods would help clarify expectations.

src/Database/Validator/Queries/V2.php (2)

188-225: Avoid undefined type access for unknown nested attributes in schemaless mode

When $supportForAttributes is false and an attribute is not in $this->schema, $attribute becomes []. For nested attributes, the later checks still dereference $attribute['type']:

$attribute = $this->schema[$collection->getId()][$attributeId] ?? [];

if (empty($attribute) && $this->supportForAttributes) {
    throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
}

if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
    throw new \Exception('Only nested relationships allowed');
}

In schemaless mode (supportForAttributes = false) and empty($attribute), this will access an undefined index and can also incorrectly throw “Only nested relationships allowed” for arbitrary nested fields. This is inconsistent with validateValues(), which short-circuits when schema support is disabled and the attribute is unknown.

Recommend short-circuiting here as well:

-        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
-        if (empty($attribute) && $this->supportForAttributes) {
-            throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
-        }
+        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
+
+        if (empty($attribute)) {
+            if ($this->supportForAttributes) {
+                throw new \Exception('Invalid query: Attribute not found in schema: ' . $attributeId);
+            }
+
+            // Schemaless: attribute not in schema, skip further attribute-based checks
+            return;
+        }

         if (\in_array('encrypt', $attribute['filters'] ?? [])) {
             throw new \Exception('Cannot query encrypted attribute: ' . $attributeId);
         }

-        if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
+        if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
             throw new \Exception('Only nested relationships allowed');
         }

This aligns schemaless behavior and avoids undefined index notices.


664-674: SELECT '*' doesn’t validate alias context against QueryContext

In the TYPE_SELECT branch, you validate the AS clause and then only check attribute existence when it isn’t '*':

$asValidator = new AsValidator($query->getAttribute());
...
if ($query->getAttribute() !== '*') {
    $this->validateAttributeExist($query->getAttribute(), $query->getAlias());
}

For SELECT '*', this means the alias is never validated against QueryContext. A query like Query::select('*', alias: 'unknown') will pass isValid() as long as the alias string passes AliasValidator, but later fail when adapters try to resolve the alias.

You can at least enforce that the alias maps to a known collection when '*' is used:

                 case Query::TYPE_SELECT:
                     $asValidator = new AsValidator($query->getAttribute());
                     if (! $asValidator->isValid($query->getAs())) {
                         throw new \Exception('Invalid query: '.\ucfirst($method).' '.$asValidator->getDescription());
                     }

-                    if ($query->getAttribute() !== '*') {
-                        $this->validateAttributeExist($query->getAttribute(), $query->getAlias());
-                    }
+                    if ($query->getAttribute() === '*') {
+                        $collection = $this->context->getCollectionByAlias($query->getAlias());
+                        if ($collection->isEmpty()) {
+                            throw new \Exception('Unknown Alias context');
+                        }
+
+                        break;
+                    }
+
+                    $this->validateAttributeExist($query->getAttribute(), $query->getAlias());

                     break;

This keeps SELECT '*' lightweight (no schema expansion) while still rejecting aliases that are not defined in the context.

src/Database/Database.php (3)

5118-5123: Context‑aware casting/decode added for create paths; update/upsert still only decode.

  • Wiring QueryContext into createDocument and createDocuments and then calling $this->casting($context, …); $this->decode($context, …); keeps result typing consistent with find() and getDocument(). This is aligned with the new context‑driven API.
  • updateDocument, updateDocuments, and upsertDocumentsWithIncrease now only call decode($context, …) (no casting()), so their return types may still differ from create*/getDocument/find on adapters where normalization is handled in Database::casting() rather than the adapter. This was already flagged earlier; your new context plumbing doesn’t change that gap.

To re‑check this across the repo, you can run:

#!/bin/bash
rg -n 'function (createDocument|createDocuments|getDocument|find|updateDocument|updateDocuments|upsertDocumentsWithIncrease)\(' src/Database/Database.php -n -C5

Also applies to: 5171-5173, 5234-5238


5818-5822: Decode now context‑aware in update/upsert paths; casting gap remains.

  • Adding QueryContext in updateDocument, updateDocuments, and upsertDocumentsWithIncrease, and switching the final decode calls to $this->decode($context, …) is consistent with the rest of the context‑driven API and should make join/alias handling uniform.
  • The previous concern about missing $this->casting($context, …) before decode on these code paths still applies; without it, adapters that rely on Database::casting() for scalar normalization will continue to return raw DB types here while other methods return normalized values.

Also applies to: 5879-5893, 5974-5981, 6050-6053, 6565-6567, 6796-6800


8713-8788: Remove unused $idAdded and keep $id auto‑select relationship‑only.

  • The tail of processRelationshipQueries() correctly:
    • Collects nestedSelections for relationship.path selectors,
    • Re‑maps top‑level select attributes according to relation type/side,
    • And only auto‑adds $id when the collection actually has relationship attributes (!empty($relationships)), which matches prior guidance.
  • PHPMD is right that $idAdded is never used here; you only care about the updated $queries.

You can simplify and satisfy static analysis by discarding the second return value:

-        $queries = array_values($queries);
-
-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
-
-        return [$queries, $nestedSelections];
+        $queries = array_values($queries);
+
+        /**
+         * In order to populateDocumentsRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }
+
+        return [$queries, $nestedSelections];

Based on learnings, this also aligns with the earlier guidance to only add $id for relationship‑bearing collections and avoid unused locals.

🧹 Nitpick comments (6)
src/Database/Database.php (6)

4295-4414: getDocument’s new QueryContext + select‑only contract: check for BC and alias coverage.

  • The move to QueryContext + DocumentsValidator and deriving $selects/$nestedSelections before hitting the adapter looks coherent and matches the new context‑driven design.
  • Enforcing Only Select queries are permitted for $queries is a behavior change: any existing callers that passed non‑select filters to getDocument will now throw QueryException. Please confirm that public usage already restricts getDocument to projections only, or document this as an intentional tightening.
  • Passing $selects into getCacheKeys() and into $this->casting()/decode() should prevent projection mix‑ups on cache; this looks correct.
  • $permissions auto‑select plus conditional removal via $permissionsAdded is a nice touch and avoids leaking system fields when user didn’t ask for them.

7231-7256: Internal $id / $permissions / $sequence selects for delete paths are consistent but strict.

  • Using Query::select('$id') in the internal find / findOne calls and explicitly validating the presence of ['$permissions', '$sequence'] in the first batch document for updateDocuments() and deleteDocuments() makes these bulk operations safer and more predictable with the new validators.
  • It does, however, make these methods sensitive to user‑supplied select lists: if callers aggressively limit projections, they’ll now see QueryException("Missing required attribute … in select query"). That seems acceptable, but it’s worth calling out in API docs or migration notes.

Also applies to: 7293-7303, 7340-7366, 7433-7456, 7531-7538, 7581-7588


7520-7534: QueryContext + joins wiring for find/count/sum is well‑structured; watch a couple of edge behaviors.

  • The pattern of:
    • Building a QueryContext with main + joined collections,
    • Running DocumentsValidator V2 over the original queries,
    • Then calling $this->convertQueries($context, $queries) and, for relationships, convertRelationshipFiltersToSubqueries(...),
    • And finally delegating to the adapter with explicit selects / filters / joins / orderQueries
      is coherent and matches the new join design.
  • Per‑collection auth in find()/count() via context->addSkipAuth() and getPermissionsByType($forPermission) / getRead() looks correct for enforcing permissions across joins.
  • count() now rejects $max < 1, while sum() still documents $max = 0 as “unlimited”; if callers relied on count(..., max: 0) this is a behavior change. Consider either harmonizing semantics with sum() or clearly documenting the new expectation.
  • The default Query::orderAsc() injection in find() is unchanged, but the comment now says “In joins we should not add a default order”; if that’s the new policy, you may want to guard that append by if (empty($joins)) to avoid surprising default ordering on joined queries.

Also applies to: 7750-7771, 7795-7806, 7815-7824, 7841-7865, 7992-8006, 8009-8025, 8049-8066, 8088-8103, 8121-8133


8004-8013: Use getPermissionsByType consistently in count() like in find().

In count(), skip‑auth is based on $_collection->getRead(), while find() uses getPermissionsByType($forPermission); for read counts this is usually equivalent but less future‑proof if permission derivation changes. Consider switching to getPermissionsByType(self::PERMISSION_READ) here for parity.


8234-8327: Context‑aware decode()/casting() refactor looks solid; error type could be more specific.

  • Building $schema once per QueryContext across all collections and resolving aliases (as and alias.attribute) per select to determine the right collection and attribute is a good approach for joins.
  • Schemaless adapters falling back to setAttribute($key, $value) when no schema entry is found preserves old behavior.
  • Returning a new instance of the main collection’s document type via createDocumentInstance($context->getMainCollection()->getId(), []) ensures casting/decoding respects custom document subclasses.
  • Throwing a bare \Exception('Invalid query: Unknown Alias context') when alias lookup fails makes this path harder to catch than a QueryException; consider switching to QueryException for consistency with other query‑validation failures.

Also applies to: 8332-8446


8978-9181: convertRelationshipFiltersToSubqueries null‑on‑failure semantics may mask bad paths.

  • When a relationship path cannot be resolved or the chained lookups yield no matching IDs, the helper returns null, which find()/count()/sum() interpret as “no matches” (empty result / 0) rather than signaling a bad query.
  • Given DocumentsValidator V2 should already reject structurally invalid paths, this may be acceptable, but if the validator misses a case (e.g., typo in a relationship key), this will quietly degrade into “0 results” instead of a QueryException.

If possible, consider distinguishing “validator passed but relationship resolution failed structurally” from “no matching related documents” and turning the former into a QueryException for easier debugging.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 689a8ea and 5d04a28.

📒 Files selected for processing (5)
  • src/Database/Database.php (55 hunks)
  • src/Database/Query.php (26 hunks)
  • src/Database/QueryContext.php (1 hunks)
  • src/Database/Validator/Queries/V2.php (1 hunks)
  • src/Database/Validator/Query/Filter.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/Database/QueryContext.php
🧰 Additional context used
🧠 Learnings (7)
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
  • src/Database/Query.php
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
  • src/Database/Query.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-10-29T12:27:57.071Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 747
File: src/Database/Adapter/Mongo.php:1449-1453
Timestamp: 2025-10-29T12:27:57.071Z
Learning: In src/Database/Adapter/Mongo.php, when getSupportForAttributes() returns false (schemaless mode), the updateDocument method intentionally uses a raw document without $set operator for replacement-style updates, as confirmed by the repository maintainer ArnabChatterjee20k.

Applied to files:

  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-10-16T09:37:33.531Z
Learnt from: fogelito
Repo: utopia-php/database PR: 733
File: src/Database/Adapter/MariaDB.php:1801-1806
Timestamp: 2025-10-16T09:37:33.531Z
Learning: In the MariaDB adapter (src/Database/Adapter/MariaDB.php), only duplicate `_uid` violations should throw `DuplicateException`. All other unique constraint violations, including `PRIMARY` key collisions on the internal `_id` field, should throw `UniqueException`. This is the intended design to distinguish between user-facing document duplicates and internal/user-defined unique constraint violations.

Applied to files:

  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-08-14T06:35:30.429Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 661
File: tests/e2e/Adapter/Scopes/SpatialTests.php:180-186
Timestamp: 2025-08-14T06:35:30.429Z
Learning: Query::distance method in Utopia\Database\Query expects an array of values parameter, where each value is a [geometry, distance] pair. So the correct format is Query::distance('attribute', [[[lat, lng], distance]]) where the outer array contains the values and each value is [geometry, distance].

Applied to files:

  • src/Database/Query.php
🪛 PHPMD (2.15.0)
src/Database/Database.php

8785-8785: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Unit Test
🔇 Additional comments (5)
src/Database/Database.php (5)

34-36: Validator alias switch to DocumentsValidator looks consistent.

The import of Queries\V2 as DocumentsValidator aligns with its usage throughout the file; no issues spotted here.


4532-4560: Nested relationship select processing and post‑batch select filtering appear sound.

  • Using processRelationshipQueries() again for nested breadth‑first traversal, and then applySelectFiltersToDocuments() to reduce related docs to explicitly selected fields while preserving internal $* attributes, is consistent with the rest of the relationship pipeline.
  • The attributesToKeep map keyed by selectQuery->getAttribute() plus wildcard handling (*) gives predictable behavior and preserves internals via $id/other $ attributes.

Also applies to: 4988-5013


6141-6171: Ensuring $id is explicitly selected in relationship maintenance helpers is a good hardening.

  • Adding Query::select('$id') to the various getDocument/findOne/find calls inside updateDocumentRelationships() prevents the new validators from rejecting these internal lookups and makes the required projection explicit.
  • The changes respect skipRelationships() and existing authorization behavior and look consistent across the different relation types.

Also applies to: 6202-6204, 6283-6299, 6327-6339, 6407-6413


8542-8615: convertQueries/convertQuery alias‑aware conversion matches the new context model.

  • Recursing into nested/join queries first and then using QueryContext::getCollectionByAlias($query->getAlias()) to locate the schema before setting onArray, attributeType, and date‑normalized values is the right direction for joins.
  • Short‑circuiting for TYPE_SELECT avoids touching projection queries, which is desirable.
  • Using the adapter’s UTC casting support in the datetime branch keeps temporal behavior consistent across adapters.

8651-8684: Select‑sensitive cache keys are correct; purge path still uses the unparameterized key.

  • Extending getCacheKeys() to accept $selects and fold them into $documentHashKey via md5(serialize($selects)) will prevent cross‑pollution between different projections of the same document, which is good.
  • purgeCachedDocumentInternal() intentionally calls getCacheKeys($collectionId, $id) without selects, ensuring it blows away the entire cache entry for that document, regardless of projection. This is the desired behavior; no issues spotted.

Copy link
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: 0

♻️ Duplicate comments (4)
tests/e2e/Adapter/Scopes/DocumentTests.php (1)

4235-4238: Decode still operates on the original $document instead of the encoded result

Here you build a QueryContext and then call:

$context = new QueryContext();
$context->add($collection);

$result = $database->decode($context, $document);

Given the prior $result = $database->encode($collection, $document);, this continues to decode the original logical $document rather than the encoded $result, so the test doesn’t exercise the actual encode→decode round‑trip path. This is the same concern raised in an earlier review.

A clearer and more realistic pipeline would be:

-        $result = $database->encode($collection, $document);
+        $encoded = $database->encode($collection, $document);-        $context = new QueryContext();
-        $context->add($collection);
-
-        $result = $database->decode($context, $document);
+        $context = new QueryContext();
+        $context->add($collection);
+
+        $result = $database->decode($context, $encoded);

(or alternatively introduce a $decoded variable and update the following assertions to use it).

If decoding the raw $document is intentional (to unit‑test decode independently of encode), an inline comment to that effect would avoid confusion.

src/Database/Validator/Queries/V2.php (3)

179-226: Guard schemaless attribute lookups to avoid undefined index and over-strict checks

When $supportForAttributes is false and the attribute is not present in $this->schema, $attribute is an empty array. The subsequent checks still dereference $attribute['type'], which will trigger undefined index notices and also enforce relationship / order-by rules in schemaless mode where no type information is available.

You already short‑circuit in validateValues() for this case; do the same here so schemaless behaves consistently and safely:

-        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
-        if (empty($attribute) && $this->supportForAttributes) {
-            throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
-        }
-
-        if (\in_array('encrypt', $attribute['filters'] ?? [])) {
+        $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
+
+        if (empty($attribute)) {
+            if ($this->supportForAttributes) {
+                throw new \Exception('Invalid query: Attribute not found in schema: '.$attributeId);
+            }
+
+            // Schemaless: attribute not in schema, skip further attribute-based checks
+            return;
+        }
+
+        if (\in_array('encrypt', $attribute['filters'] ?? [])) {
             throw new \Exception('Cannot query encrypted attribute: ' . $attributeId);
         }
 
-        if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
+        if ($isNested && $attribute['type'] != Database::VAR_RELATIONSHIP) {
             throw new \Exception('Only nested relationships allowed');
         }
 
         if ($isNested && \in_array($method, [Query::TYPE_ORDER_ASC, Query::TYPE_ORDER_DESC])) {
             throw new \Exception('Cannot order by nested attribute: ' . $attributeId);
         }

Based on learnings, this keeps schemaless mode permissive while preserving strict validation when attributes are supported.


530-533: Restrict join-scope alias validation to relation queries to avoid rejecting valid filters

The generic scope === 'joins' alias check currently runs for all nested queries under a join:

if ($scope === 'joins') {
    if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)) {
        throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' alias reference in join has not been defined.');
    }
}

For normal filter/order queries inside a join, getRightAlias() is typically empty, so in_array('', $this->joinsAliasOrder) fails and valid queries get rejected. Only relation queries really need this alias-order validation.

Narrow the check to relation queries and use strict comparisons:

-                if ($scope === 'joins') {
-                    if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)) {
-                        throw new \Exception('Invalid query: '.\ucfirst($query->getMethod()).' alias reference in join has not been defined.');
-                    }
-                }
+                if ($scope === 'joins' && $query->getMethod() === Query::TYPE_RELATION_EQUAL) {
+                    if (
+                        ! \in_array($query->getAlias(), $this->joinsAliasOrder, true)
+                        || ! \in_array($query->getRightAlias(), $this->joinsAliasOrder, true)
+                    ) {
+                        throw new \Exception(
+                            'Invalid query: ' . \ucfirst($query->getMethod()) . ' alias reference in join has not been defined.'
+                        );
+                    }
+                }

This preserves alias validation for relations while allowing non‑relation filters and orders within joins.


664-674: Ensure SELECT * also validates alias existence against the context

In the TYPE_SELECT branch, attribute-specific selects go through validateAttributeExist(), which verifies that the alias maps to a collection in the QueryContext. For SELECT '*', you now skip that check entirely, so a query with an unknown alias can pass validation here and fail later in the pipeline.

You can keep the simplified '*' handling while still validating the alias context:

-                    case Query::TYPE_SELECT:
-                        $asValidator = new AsValidator($query->getAttribute());
-                        if (! $asValidator->isValid($query->getAs())) {
-                            throw new \Exception('Invalid query: '.\ucfirst($method).' '.$asValidator->getDescription());
-                        }
-
-                        if ($query->getAttribute() !== '*') {
-                            $this->validateAttributeExist($query->getAttribute(), $query->getAlias());
-                        }
-
-                        break;
+                    case Query::TYPE_SELECT:
+                        $asValidator = new AsValidator($query->getAttribute());
+                        if (! $asValidator->isValid($query->getAs())) {
+                            throw new \Exception('Invalid query: '.\ucfirst($method).' '.$asValidator->getDescription());
+                        }
+
+                        if ($query->getAttribute() === '*') {
+                            $collection = $this->context->getCollectionByAlias($query->getAlias());
+                            if ($collection->isEmpty()) {
+                                throw new \Exception('Invalid query: Unknown Alias context');
+                            }
+
+                            break;
+                        }
+
+                        $this->validateAttributeExist($query->getAttribute(), $query->getAlias());
+
+                        break;

This restores the “unknown alias is invalid” guarantee for SELECT * without reintroducing the previous schema-iteration logic.

🧹 Nitpick comments (1)
tests/e2e/Adapter/Scopes/DocumentTests.php (1)

3153-3162: Consider asserting selected attribute presence in testOrNested

Adding Query::select('director') ahead of the logical queries is a good way to ensure projections interact correctly with nested or conditions and that unselected fields (name) are omitted.

You might strengthen this test slightly by also asserting that director is present (e.g. assertArrayHasKey('director', $documents[0])), so the test verifies both exclusion of unselected attributes and inclusion of the explicitly selected one.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d04a28 and 2e7569d.

📒 Files selected for processing (2)
  • src/Database/Validator/Queries/V2.php (1 hunks)
  • tests/e2e/Adapter/Scopes/DocumentTests.php (18 hunks)
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-10-29T12:27:57.071Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 747
File: src/Database/Adapter/Mongo.php:1449-1453
Timestamp: 2025-10-29T12:27:57.071Z
Learning: In src/Database/Adapter/Mongo.php, when getSupportForAttributes() returns false (schemaless mode), the updateDocument method intentionally uses a raw document without $set operator for replacement-style updates, as confirmed by the repository maintainer ArnabChatterjee20k.

Applied to files:

  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Validator/Queries/V2.php
📚 Learning: 2025-10-16T09:37:33.531Z
Learnt from: fogelito
Repo: utopia-php/database PR: 733
File: src/Database/Adapter/MariaDB.php:1801-1806
Timestamp: 2025-10-16T09:37:33.531Z
Learning: In the MariaDB adapter (src/Database/Adapter/MariaDB.php), only duplicate `_uid` violations should throw `DuplicateException`. All other unique constraint violations, including `PRIMARY` key collisions on the internal `_id` field, should throw `UniqueException`. This is the intended design to distinguish between user-facing document duplicates and internal/user-defined unique constraint violations.

Applied to files:

  • src/Database/Validator/Queries/V2.php
🧬 Code graph analysis (1)
tests/e2e/Adapter/Scopes/DocumentTests.php (1)
src/Database/QueryContext.php (2)
  • QueryContext (8-136)
  • add (68-76)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Unit Test
🔇 Additional comments (7)
tests/e2e/Adapter/Scopes/DocumentTests.php (7)

22-22: Import of QueryContext matches new context-aware decode usage

Bringing QueryContext into scope is consistent with the updated decode(QueryContext $context, ...) API used later in testEncodeDecode; no issues here.


1397-1435: Per‑field select and internal field assertions for getDocument look correct

The switch to Query::select('string') / Query::select('integer_signed') and the explicit assertions that $id, $sequence, $createdAt, $updatedAt, and $permissions are absent unless explicitly selected (while $collection is always present) accurately exercise the new select semantics for getDocument. The new testGetDocumentOnlySelectQueries also cleanly enforces that only select queries are accepted in the third parameter, with clear error messaging.


1437-1470: Guarding getDocument against non‑select queries is well covered

testGetDocumentOnlySelectQueries correctly verifies that passing Query::equal or Query::limit into getDocument triggers a DatabaseException with the expected message, ensuring callers can’t accidentally mix filtering/paging with the “projection only” API. Test structure and expectations are solid.


1955-1959: notSearch extension in full‑text test aligns with new operator

Adding the Query::notSearch('name', 'captain') branch here complements the positive search check and matches the dataset (2 captain movies + 4 non‑captain); this is a good, minimal sanity check that the negative full‑text operator is wired correctly.


3748-3885: Expanded testFindSelect thoroughly validates internal field projection rules

The refactored testFindSelect now exercises many combinations of:

  • User attributes (name, year)
  • System attributes ($id, $sequence, $collection, $createdAt, $updatedAt, $permissions)

via repeated Query::select('field') calls, with assertions that:

  • Only explicitly selected internal attributes appear.
  • $collection remains present even when not selected.
  • Non‑selected internal attributes are absent.

This directly tests and documents the new behavior that internal fields are not auto‑selected apart from $collection, which aligns with the QueryContext/relationship‑processing learning about not adding $id unless needed. Based on learnings, this coverage looks appropriate and consistent.


4981-4988: Using select projections in bulk delete tests is consistent with new semantics

In the bulk delete tests you now prepend Query::select(...) (for $createdAt, $sequence, $id, $collection, $permissions, $updatedAt) to the deleteDocuments query arrays. That:

  • Exercises the new projection behavior with cursor and ordering queries (cursorAfter, greaterThan, orderAsc, limit).
  • Ensures the adapter has all necessary system fields for paging/deletion while still validating that projection queries are accepted in non‑read operations.

The patterns and field sets you’re selecting match what deletion and cursor logic need; the tests remain clear and should be stable across adapters.

Also applies to: 5190-5196, 5213-5220


5465-5467: select('*') in testEmptyTenant shared‑tables path is reasonable

Switching the shared‑tables branch to query via Query::select('*') (rather than a filtering predicate) to work around the Mongo integer UID bug is compatible with the new per‑field select API and keeps the core assertions about $tenant unchanged. As long as '*' is treated as “all fields” by the query layer, this is a straightforward and appropriate adjustment.

Copy link
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: 0

🧹 Nitpick comments (4)
tests/unit/Validator/Query/FilterTest.php (4)

8-81: V2 validator wiring and QueryContext setup looks consistent

The metadata collection, attributes (including search), fulltext index, and QueryContext wiring all line up with how DocumentsValidator (V2) expects schema/context, and maxValuesCount is passed in explicitly. This gives the tests a realistic environment without overcomplicating setup. Only nit: if multiple test classes need a similar context, consider extracting a small factory/helper to avoid duplication, but fine to defer.


110-124: Failure-path coverage for invalid attributes and ranges is appropriate

testFailure() targets invalid attribute names, invalid limits/offsets, cursor misuse, and type/shape mismatches (e.g. contains on scalar, equal on array attribute). This gives good negative coverage of the new V2 behavior. If you later tighten specific error messages for limits/offsets or cursors, you might add explicit assertEquals for those descriptions as well, but it’s not strictly necessary now.


168-184: Clarify comments in testNotSearch()

The assertions themselves look correct (valid on search with FT index, invalid on non-indexed and array attributes, and enforcing exactly one value). However:

  • Line 172’s comment “Test valid notSearch queries” is followed by failing assertions.
  • The later comments are accurate in behavior but could be clearer about what is being validated (index requirement vs shape vs value-count).

Consider tightening the comments, for example:

-        // Test valid notSearch queries
+        // Missing fulltext index should be rejected

-        // Test that arrays cannot use notSearch
+        // Arrays cannot be used with notSearch

-        // Test multiple values not allowed
+        // NotSearch must receive exactly one value

This keeps future readers from misinterpreting the scenario.


186-202: Search tests mirror NotSearch well; fix copy-paste comments

The testSearch() logic correctly mirrors testNotSearch() (valid on search with FT index, rejected for non-indexed and array attributes, and enforcing exactly one value). The only nits are copy-paste comments that still mention notSearch:

-        // Test valid notSearch queries
+        // Missing fulltext index should be rejected

-        // Test that arrays cannot use notSearch
+        // Arrays cannot be used with search

-        // Test multiple values not allowed
+        // Search must receive exactly one value

Updating these will make the test intent clearer without changing behavior.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e7569d and d3f2e8a.

📒 Files selected for processing (1)
  • tests/unit/Validator/Query/FilterTest.php (2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • tests/unit/Validator/Query/FilterTest.php
🧬 Code graph analysis (1)
tests/unit/Validator/Query/FilterTest.php (2)
src/Database/QueryContext.php (2)
  • QueryContext (8-136)
  • add (68-76)
src/Database/Validator/Queries/V2.php (2)
  • V2 (23-714)
  • getDescription (135-138)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (MariaDB)
🔇 Additional comments (4)
tests/unit/Validator/Query/FilterTest.php (4)

84-107: Positive-case coverage for filters and non-filter queries is solid

The testSuccess() method exercises a good spread of filter methods and also asserts that non-filter queries (limit, offset, orderAsc, orderDesc) are accepted. This aligns well with the intent that the validator now allows these through. All calls use the new array-of-queries contract correctly.


126-154: Type-mismatch and max-values checks match V2 maxValuesCount semantics

testTypeMismatch(), testEmptyValues(), and testMaxValuesCount() now assert the Invalid query: ...-prefixed messages and use the local $maxValuesCount property, which matches the documented V2 behavior and the constructor signature. This should keep the tests stable if the global defaults change.


156-166: NotContains behavior is well covered

testNotContains() verifies both valid and invalid (empty-values) cases and aligns the expected message with the new Invalid query: prefix. This addresses prior feedback and clearly documents the minimum-values constraint for notContains.


204-251: NotStartsWith / NotEndsWith / NotBetween coverage looks complete

The three tests cover:

  • Happy-path usage on scalar attributes.
  • Rejection when targeting array attributes.
  • Rejection when the number of values is wrong for notBetween, with precise error messages.

These are well aligned with the V2 semantics and use the array-of-queries contract consistently.

Copy link
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Database/Database.php (2)

8234-8327: Use query-specific exceptions instead of generic \Exception in decode/casting when alias context is unknown

Both decode() and casting() now rely on QueryContext and select metadata to resolve collection aliases:

$collection = $context->getCollectionByAlias($alias);
if ($collection->isEmpty()) {
    throw new \Exception('Invalid query: Unknown Alias context');
}

Throwing bare \Exception here makes it harder for callers to distinguish:

  • genuine query-construction errors (bad alias, malformed select),
  • from other runtime failures.

Since this is clearly a query-level problem, using QueryException (or another domain-specific exception already used elsewhere) would be more consistent and easier to handle upstream.

Targeted change
-            if ($collection->isEmpty()) {
-                throw new \Exception('Invalid query: Unknown Alias context');
-            }
+            if ($collection->isEmpty()) {
+                throw new QueryException('Invalid query: Unknown Alias context');
+            }

Apply in both decode() and casting() where this pattern appears.

Also applies to: 8338-8446


8978-9181: convertRelationshipFiltersToSubqueries swallows all exceptions and turns them into “no matches”

convertRelationshipFiltersToSubqueries() wraps the entire relationship-filter resolution in a broad try { … } catch (\Exception $e) { return null; }. Callers then interpret null as “no documents can match”:

  • find() treats null as “return empty $results”.
  • count() returns 0.
  • sum() returns 0.

This means that any error encountered while resolving relationship filters — including:

  • authorization failures,
  • timeouts,
  • malformed queries / invalid attributes,
  • adapter/SQL errors —

will silently degrade into “no results”, which is a significant behavior change and makes operational debugging very hard.

I’d strongly recommend narrowing this:

  • Let most exceptions bubble (AuthorizationException, TimeoutException, DatabaseException, QueryException, etc.).
  • If you specifically want to treat “relationship filter finds no IDs” as “no matches”, handle that by checking for an empty $matchingIds / $parentIds, not by catching all \Exception.

For example (conceptually):

try {
    // current logic
} catch (QueryException $e) {
    // rethrow as-is so clients see a query error
    throw $e;
} catch (DatabaseException|AuthorizationException|TimeoutException $e) {
    // propagate real runtime failures
    throw $e;
}

and only return null when the algorithm logically determines there are no matching IDs.

♻️ Duplicate comments (4)
tests/e2e/Adapter/Scopes/DocumentTests.php (1)

4253-4281: Decode still operates on the original $document instead of the encoded $result

This test currently:

$result = $database->encode($collection, $document);
// …
$context = new QueryContext();
$context->add($collection);

$result = $database->decode($context, $document);

So the decode step is applied to the original $document, not to the encoded $result. That means you’re testing encode and decode largely independently rather than validating the full encode→decode pipeline on the same payload (including any system‑injected selects influenced by QueryContext).

To exercise the round‑trip more realistically, consider decoding the encoded document instead, or add a brief comment clarifying that the intent is to test decode in isolation.

Suggested change to round‑trip the encoded document
-        $result = $database->encode($collection, $document);
+        $result = $database->encode($collection, $document);
@@
-        $context = new QueryContext();
-        $context->add($collection);
-
-        $result = $database->decode($context, $document);
+        $context = new QueryContext();
+        $context->add($collection);
+
+        $result = $database->decode($context, $result);
src/Database/Adapter/Mongo.php (1)

1859-2065: Context-driven find() implementation is solid; consider guards and unused params

  • The new find(QueryContext $context, …, array $selects, array $filters, array $joins, array $vectors, array $orderQueries) correctly:

    • pulls the main collection from QueryContext,
    • clones and runs $filters through buildFilters() and tenant filters,
    • applies auth via QueryContext::skipAuth() and _permissions regex,
    • derives projections from getAttributeProjection($selects) and hides $sequence when _id is not projected,
    • builds cursor-based pagination filters from $orderQueries and applies sort and $or logic,
    • iterates the Mongo cursor in batches with transaction options and cleans up cursors.
  • Two follow-ups to consider:

    1. $joins and $vectors are intentionally unused (Mongo doesn’t support relationships/vectors), but they trigger static analysis warnings. You can optionally silence those by explicitly unsetting them at the top of the method:
      unset($joins, $vectors);
      This makes intent clear without changing behavior.
    2. The prior review note about orderRandom still applies: getOrderDirection() will throw if a TYPE_ORDER_RANDOM query ever reaches this method, while getSupportForOrderRandom() returns false for Mongo. If higher layers don’t already filter such queries, adding a small guard here (e.g., reject/skip random-order queries up front with a clearer exception) would make this more robust.
src/Database/Database.php (2)

5818-5822: Update/upsert paths still skip casting(QueryContext, …), leading to inconsistent type normalization

Compared to createDocument, createDocuments, getDocument, and find, these paths only call decode() and not casting():

  • Line 5818–5822: updateDocument() builds a QueryContext and then calls only $this->decode($context, $document);.
  • Line 6048–6053: updateDocuments() calls $doc = $this->adapter->castingAfter(...); and then only $this->decode($context, $doc);.
  • Line 6796–6800: upsertDocumentsWithIncrease() decodes outputs when !$hasOperators but never calls casting().

Given that:

  • casting() is where scalar types are normalized according to the schema (bool/int/float/id), and
  • All other CRUD read paths already run casting($context, …) before decode(),

these update/upsert methods can return different PHP types for the same attributes (typically raw DB types) compared to create/get/find, depending on the adapter. This is exactly the inconsistency flagged in the earlier review and still present in the current code.

I’d recommend aligning these paths by inserting casting() before decode() in all three places, e.g.:

Suggested diffs to normalize types on update/upsert
@@ 5818-5822
-        $context = new QueryContext();
-        $context->add($collection);
-
-        $document = $this->decode($context, $document);
+        $context = new QueryContext();
+        $context->add($collection);
+
+        $document = $this->casting($context, $document);
+        $document = $this->decode($context, $document);
@@ 6048-6053
-            foreach ($batch as $index => $doc) {
-                $doc = $this->adapter->castingAfter($collection, $doc);
-                $doc->removeAttribute('$skipPermissionsUpdate');
-                $this->purgeCachedDocument($collection->getId(), $doc->getId());
-                $doc = $this->decode($context, $doc);
+            foreach ($batch as $index => $doc) {
+                $doc = $this->adapter->castingAfter($collection, $doc);
+                $doc->removeAttribute('$skipPermissionsUpdate');
+                $this->purgeCachedDocument($collection->getId(), $doc->getId());
+                $doc = $this->casting($context, $doc);
+                $doc = $this->decode($context, $doc);
@@ 6796-6800
-            foreach ($batch as $index => $doc) {
-                $doc = $this->adapter->castingAfter($collection, $doc);
-                if (!$hasOperators) {
-                    $doc = $this->decode($context, $doc);
-                }
+            foreach ($batch as $index => $doc) {
+                $doc = $this->adapter->castingAfter($collection, $doc);
+                if (!$hasOperators) {
+                    $doc = $this->casting($context, $doc);
+                    $doc = $this->decode($context, $doc);
+                }

Also applies to: 6048-6053, 6796-6800


8713-8788: Remove unused $idAdded in processRelationshipQueries and discard second return value

processRelationshipQueries()’ tail currently is:

$queries = array_values($queries);

/**
 * In order to populateDocumentRelationships we need $id
 */
if (!empty($relationships)) {
    [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
}

$idAdded is never used, which PHPMD correctly flags, and the logic you want is just to ensure $id is present when there are relationship attributes.

You can both satisfy static analysis and match the earlier guidance (“only when there are relationships, don’t keep an unused var”) by discarding the second return element:

Suggested fix
-        $queries = array_values($queries);
-
-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
-
-        return [$queries, $nestedSelections];
+        $queries = array_values($queries);
+
+        /**
+         * In order to populateDocumentRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }
+
+        return [$queries, $nestedSelections];

This keeps the behavior (auto-select $id only when relationships exist) and removes the unused local, in line with the stored learning.

🧹 Nitpick comments (5)
tests/e2e/Adapter/Scopes/DocumentTests.php (3)

1439-1476: Projection behaviour in testGetDocumentSelect looks correct; consider tightening the float assertion

The new assertions around $id, $sequence, $createdAt, $updatedAt, and $permissions align well with the new “only explicitly‑selected system fields are exposed” behaviour and look sound.

One small improvement: Line 1449 currently asserts that the attribute 'float' is absent, but the attributes defined in testCreateDocument are float_signed and float_unsigned. As written, the 'float' check is effectively a no‑op even if either float field slipped into the projection. Updating this to assert on the concrete attribute names would make the test stricter and more representative of the schema.

Example tightening for the float assertion
-        $this->assertArrayNotHasKey('float', $document->getAttributes());
+        $this->assertArrayNotHasKey('float_signed', $document->getAttributes());
+        $this->assertArrayNotHasKey('float_unsigned', $document->getAttributes());

1979-2002: notSearch assertions are duplicated between testFindFulltext and testFindNotSearch

The new Query::notSearch('name', 'captain') block in testFindFulltext is functionally very similar to the primary assertions in testFindNotSearch below. That’s not wrong, but it does couple two tests to the same behaviour and index setup.

If you want to keep the suite lean, consider moving this assertion (and any future notSearch scenarios) into testFindNotSearch so all negative full‑text behaviour is exercised in one place.


5505-5510: Clarify the “Mongo bug with Integer UID” comment now that the query is only Query::select('*')

In the shared‑tables branch of testEmptyTenant, the query was changed to:

$documents = $database->find(
    'documents',
    [Query::select('*')] // Mongo bug with Integer UID
);

The inline comment still references a “Mongo bug with Integer UID”, but the query no longer has any obvious filter related to that bug (unlike the non‑shared‑tables branch below, which uses Query::notEqual('$id', '56000') with the same comment).

For future readers, it might be worth either updating the comment to explain why an explicit select('*') is required here, or adjusting the query/comment pair so it’s clear how this line mitigates the referenced Mongo issue.

src/Database/Adapter/Mongo.php (2)

1125-1156: Projection in getDocument() assumes $queries are selects — please verify

getDocument() now derives $projections via getAttributeProjection($queries) and uses $removeSequence to hide $sequence when _id is not projected. This is sound if $queries here are strictly TYPE_SELECT queries; if callers still pass filter queries in $queries, you’ll end up with a projection limited to attributes mentioned in filters, which could be a behavior change.

If that’s not intended, consider passing only select queries into getAttributeProjection() (mirroring how find() uses $selects).


2131-2226: count() aligns with new QueryContext flow; joins unused but harmless

count(QueryContext $context, ?int $max, array $filters = [], array $joins = []) now:

  • Uses getMainCollection() and clones $filters before passing them through buildFilters().
  • Applies tenant filters and READ-permission auth via QueryContext::skipAuth() and a _permissions regex.
  • Respects $this->timeout via maxTimeMS in $options.
  • Uses an aggregation pipeline with $match, optional $limit, and either $group+$sum or $count, which is appropriate for accurate counting (including sharded clusters).

The $joins parameter is currently unused (Mongo doesn’t support joins), which is expected but flagged by static analysis. If you want to quiet those warnings without affecting behavior, you can explicitly unset($joins); at the top of the method.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d3f2e8a and a732ab4.

📒 Files selected for processing (7)
  • src/Database/Adapter.php (3 hunks)
  • src/Database/Adapter/MariaDB.php (2 hunks)
  • src/Database/Adapter/Mongo.php (14 hunks)
  • src/Database/Adapter/Pool.php (4 hunks)
  • src/Database/Adapter/Postgres.php (2 hunks)
  • src/Database/Database.php (55 hunks)
  • tests/e2e/Adapter/Scopes/DocumentTests.php (18 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/Database/Adapter/Postgres.php
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Adapter.php
  • src/Database/Database.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • src/Database/Adapter/Mongo.php
📚 Learning: 2025-10-29T12:27:57.071Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 747
File: src/Database/Adapter/Mongo.php:1449-1453
Timestamp: 2025-10-29T12:27:57.071Z
Learning: In src/Database/Adapter/Mongo.php, when getSupportForAttributes() returns false (schemaless mode), the updateDocument method intentionally uses a raw document without $set operator for replacement-style updates, as confirmed by the repository maintainer ArnabChatterjee20k.

Applied to files:

  • src/Database/Adapter.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • src/Database/Adapter/Mongo.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Adapter/MariaDB.php
  • src/Database/Database.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
🧬 Code graph analysis (3)
src/Database/Adapter/Pool.php (2)
src/Database/QueryContext.php (1)
  • QueryContext (8-136)
src/Database/Adapter.php (3)
  • find (829-841)
  • count (865-870)
  • getAttributeProjection (1256-1256)
src/Database/Adapter/MariaDB.php (1)
src/Database/Query.php (5)
  • setAttributeRight (331-336)
  • getAttributeRight (287-290)
  • getAttribute (255-258)
  • Query (8-1559)
  • getRightAlias (282-285)
tests/e2e/Adapter/Scopes/DocumentTests.php (3)
src/Database/QueryContext.php (2)
  • QueryContext (8-136)
  • add (68-76)
src/Database/Query.php (2)
  • Query (8-1559)
  • select (788-791)
src/Database/Adapter/SQL.php (1)
  • getDocument (364-429)
🪛 PHPMD (2.15.0)
src/Database/Database.php

8785-8785: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

src/Database/Adapter/Mongo.php

1884-1884: Avoid unused parameters such as '$joins'. (undefined)

(UnusedFormalParameter)


1885-1885: Avoid unused parameters such as '$vectors'. (undefined)

(UnusedFormalParameter)


2140-2140: Avoid unused parameters such as '$joins'. (undefined)

(UnusedFormalParameter)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (10)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (SQLite)
🔇 Additional comments (9)
tests/e2e/Adapter/Scopes/DocumentTests.php (2)

1479-1512: Good guard that getDocument’s third argument is projection‑only

testGetDocumentOnlySelectQueries clearly enforces that passing non‑select queries (e.g. equal, limit) into the $queries argument of getDocument fails with the expected DatabaseException and message. This is a nice, targeted regression test for the new projection‑only contract.


3785-3927: Expanded testFindSelect coverage for system fields and projections looks solid

The additional find('movies', [... Query::select(...) ...]) cases exercising $id, $sequence, $collection, $createdAt, $updatedAt, and $permissions being included or excluded only when explicitly selected give very good coverage of the new projection rules, across both instance ($database) and static (static::getDatabase()) access paths. The expectations on which system keys are present in each scenario are consistent and should catch regressions in the projection logic.

src/Database/Adapter/MariaDB.php (1)

1548-1603: Relation/equality condition handling looks consistent and safe

Normalizing both left and right attributes via getInternalKeyForAttribute(), then filtering and quoting aliases/columns (including the new TYPE_RELATION_EQUAL branch) keeps join-style predicates consistent with other conditions and prevents unsafe identifiers. I don’t see correctness or security issues here.

src/Database/Adapter/Pool.php (1)

9-9: QueryContext-based signatures and delegation are correctly wired

Pool::find, Pool::count, and getAttributeProjection now match the updated Adapter signatures (including QueryContext and the new select/filter/join arrays) while still delegating transparently via delegate(). No issues spotted with type hints, defaults, or argument forwarding.

Also applies to: 271-283, 292-299, 511-514

src/Database/Adapter.php (1)

815-841: Context-first find/count API and projection contract look coherent

The abstract find/count methods now correctly pivot to QueryContext plus explicit $selects/$filters/$joins/$orderQueries, and getAttributeProjection(array $selects) provides a clear, narrower contract for per-field projection. The signatures and docblocks are consistent and line up with the adapter implementations in this PR.

Also applies to: 855-870, 1253-1256

src/Database/Adapter/Mongo.php (3)

711-723: renameAttribute correctly maps public names to internal Mongo keys

Renaming now goes through getInternalKeyForAttribute($old|$new), so callers can pass public names like $id/$sequence and still have the right _uid/_id fields renamed internally. The additional filter() calls keep collection and field identifiers safe.


1790-1799: Using buildFilters() for bulk delete by $sequence keeps ID handling consistent

Switching deleteDocuments() to build filters via buildFilters([Query::equal('$sequence', $sequences)]) means the $sequence_id mapping and operator semantics are centralized in buildFilter(). This reduces duplication and keeps Mongo’s internal ID handling aligned with other query paths.


2561-2595: getAttributeProjection() behavior matches the new projection contract

The new getAttributeProjection(array $selects) correctly:

  • Returns [] when there are no selects or when a * select is present, signalling “no projection override”.
  • Skips $collection, which isn’t a stored field.
  • Maps public attributes ($id, $sequence, $tenant, $createdAt, $updatedAt, $permissions) to their internal Mongo field names and marks them as included.
  • Produces a simple ['fieldName' => 1, …] projection array that’s compatible with both getDocument() and find()’s $removeSequence logic.

This is consistent with the rest of the adapter and the shared Adapter contract.

src/Database/Database.php (1)

4295-4331: getDocument now enforces that all queries must be SELECT queries

getDocument() now rejects non-SELECT queries (filters, limits, ordering) by comparing Query::getSelectQueries($queries) against the input and throwing QueryException('Only Select queries are permitted') if any non-SELECT query is present. This behavioral change is intentional and has dedicated test coverage in testGetDocumentOnlySelectQueries (verifying that Query::equal() and Query::limit() are rejected).

This is a breaking change for any callers passing non-SELECT queries, though getDocument signature has never documented support for such queries. The implementation is correct: queries are filtered to SELECT-only before validation occurs.

Comment on lines +5974 to +5981
/**
* Check and tests for required attributes
*/
foreach (['$permissions', '$sequence'] as $required) {
if (!$batch[0]->offsetExists($required)) {
throw new QueryException("Missing required attribute {$required} in select query");
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Bulk update/delete require $permissions & $sequence in selects but don’t auto-add them

In both bulk paths:

  • updateDocuments() (Lines 5974–5981)
  • deleteDocuments() (Lines 7581–7588)

you enforce:

foreach (['$permissions', '$sequence'] as $required) {
    if (!$batch[0]->offsetExists($required)) {
        throw new QueryException("Missing required attribute {$required} in select query");
    }
}

However, find() does not automatically add system selects for $permissions and $sequence the way getDocument() now does for $permissions. That means:

  • Callers who add any Query::select(...) to the bulk update/delete queries must remember to also select both $permissions and $sequence, or they’ll get a runtime QueryException.
  • This is a bit non-obvious and asymmetric with getDocument()’s behavior.

Consider either:

  • Automatically injecting these system selects (similar to how you auto-inject $id for relationships and $permissions in getDocument()), or
  • Explicitly documenting that any custom SELECTs used with updateDocuments / deleteDocuments must include $permissions and $sequence.

Also applies to: 7581-7588

Comment on lines 7992 to +8003
public function count(string $collection, array $queries = [], ?int $max = null): int
{
if (!is_null($max) && $max < 1) {
throw new DatabaseException('Invalid max value, must be a valid integer and greater than 0');
}

$collection = $this->silent(fn () => $this->getCollection($collection));
$attributes = $collection->getAttribute('attributes', []);
$indexes = $collection->getAttribute('indexes', []);

if ($collection->isEmpty()) {
throw new NotFoundException('Collection not found');
}

Copy link
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:

# First, check if the file exists and get its size
if [ -f "src/Database/Database.php" ]; then
  echo "File found"
  wc -l src/Database/Database.php
else
  echo "File not found at src/Database/Database.php"
  # Try to find it
  find . -name "Database.php" -type f | head -20
fi

Repository: utopia-php/database

Length of output: 104


🏁 Script executed:

# Read the count() method around lines 7992-8003
echo "=== COUNT METHOD (lines 7992-8003) ==="
sed -n '7980,8015p' src/Database/Database.php | cat -n

echo -e "\n=== SUM METHOD (lines 8086-8091) ==="
sed -n '8075,8115p' src/Database/Database.php | cat -n

Repository: utopia-php/database

Length of output: 3394


🏁 Script executed:

# Search for other methods that have a $max parameter to check for consistency
echo "=== Methods with \$max parameter ==="
rg 'public function.*\$max' src/Database/Database.php -A 5 | head -100

# Also check what the adapter does with $max in sum
echo -e "\n=== Search for how adapter handles max in sum context ==="
rg 'adapter.*sum' src/Database/Database.php -A 3 -B 3 | head -50

Repository: utopia-php/database

Length of output: 1080


🏁 Script executed:

# Let's look for all methods that might use $max to see if there's a pattern
echo "=== All public methods with \$max parameter (more thorough search) ==="
rg 'public function.*\$max\s*=' src/Database/Database.php | grep -v setMaxQueryValues

# Check the full sum() method to see if $max is used anywhere else
echo -e "\n=== Full sum() method implementation ==="
sed -n '8075,8150p' src/Database/Database.php

# Check if there are other similar aggregate methods like avg, min, max
echo -e "\n=== Search for other aggregate methods ==="
rg 'public function (avg|min|max)\(' src/Database/Database.php -A 1

Repository: utopia-php/database

Length of output: 3225


🏁 Script executed:

# Let's check the adapter implementations to understand the $max semantics better
echo "=== Search for adapter sum method ==="
find . -name "*.php" -type f -exec grep -l "public function sum" {} \; | grep -i adapter | head -5

# Also check if there's documentation about $max semantics
echo -e "\n=== Search for all docblocks mentioning 'max' ==="
rg '\$max' src/Database/Database.php -B 2 | head -80

Repository: utopia-php/database

Length of output: 2844


🏁 Script executed:

# Let's check the Adapter base class to see the sum method signature
echo "=== Adapter base class sum() method ==="
sed -n '/public function sum/,/^[[:space:]]*}/p' src/Database/Adapter.php | head -50

# Check SQL adapter sum implementation
echo -e "\n=== SQL Adapter sum() method ==="
sed -n '/public function sum/,/^[[:space:]]*}/p' src/Database/Adapter/SQL.php | head -50

Repository: utopia-php/database

Length of output: 1841


🏁 Script executed:

# Let's check if there are other aggregate methods and their $max handling
echo "=== Check for avg, min, max aggregate methods ==="
rg 'public function (avg|minimum|maximum)\(' src/Database/Database.php -A 10 | head -50

# Let's also verify the count() method's full implementation to see how it uses $max
echo -e "\n=== Full count() method implementation ==="
sed -n '7992,8060p' src/Database/Database.php

Repository: utopia-php/database

Length of output: 2917


🏁 Script executed:

# Let's check the SQL adapter's count method too to see if it handles $max differently
echo "=== SQL Adapter count() method ==="
sed -n '/public function count/,/^[[:space:]]*}/p' src/Database/Adapter/SQL.php | head -80

# And check the full sum implementation in Database.php
echo -e "\n=== Full sum() method in Database.php (complete) ==="
sed -n '8075,8150p' src/Database/Database.php | grep -A 20 "return \$sum"

Repository: utopia-php/database

Length of output: 1367


Align $max parameter semantics between count() and sum()

count() validates and rejects $max < 1, but sum() has no validation and forwards $max directly to the adapter. The docblock for sum() claims "$max=0 for unlimited count," yet the SQL adapter would apply LIMIT 0 (limiting to 0 results), not treat 0 as a sentinel for unlimited. This inconsistency could break existing callers that rely on the stated semantics.

Recommend:

  • Clarify whether $max=0 should mean "unlimited" or "invalid"
  • Apply consistent validation across both methods
  • Update docblocks to explicitly document accepted values and their meanings
🤖 Prompt for AI Agents
In src/Database/Database.php around lines 7992 to 8003, the count() method
rejects $max < 1 while sum() accepts $max (docblock says 0 means unlimited)
which is inconsistent and will cause adapters (e.g. SQL) to apply LIMIT 0;
decide which semantics you want (treat 0 as "unlimited" or treat 0 as invalid),
then make both methods consistent: either validate and reject non-positive $max
in sum() the same way count() does, or accept 0 as unlimited in both by
converting $max === 0 to null/omitting LIMIT before calling the adapter. Update
both methods' docblocks to explicitly state the accepted values and behavior
(e.g. "$max must be > 0; pass null for unlimited" or "$max = 0 means no limit
and will be treated as unlimited") and ensure adapters receive the adjusted
value (no LIMIT 0).

Copy link
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Database.php (1)

7497-7534: Both deleteDocuments() and updateDocuments() require explicit $permissions and $sequence in custom SELECT queries.

When users provide custom select queries via the $queries parameter, they must include both $permissions and $sequence attributes. These system attributes are not auto-injected and are required by the batch delete/update logic (lines 7584-7588 for delete, 5977-5981 for update). Currently there is no auto-injection mechanism and no prominent documentation of this requirement. Consider auto-injecting these system selects when custom projections are present, or document the requirement prominently in the method docblocks.

♻️ Duplicate comments (6)
src/Database/Database.php (6)

8338-8446: casting(QueryContext,…): schema‑driven scalar casting is consistent with decode()

The new casting() mirrors decode()’s schema resolution but only performs scalar type normalization (bool/int/float, plus array JSON decode when needed) into a fresh main‑collection Document, and early‑outs when adapter->getSupportForCasting() is false.

This design is coherent and keeps casting concerns separate from filter decoding. Just ensure callers always use casting() before decode() when type normalization is required (see earlier comments on update*/upsert* paths).


5818-5822: Update/upsert return paths still skip casting(context, …) (type normalization inconsistency)

createDocument(), createDocuments(), getDocument(), and find() now all pass results through casting($context, …) before decode($context, …), but:

  • updateDocument() only calls decode($context, $document) (no casting()).
  • updateDocuments() only calls decode($context, $doc) for each batch element.
  • upsertDocumentsWithIncrease() only calls decode($context, $doc) when no operators.

That means for adapters relying on Database::casting() for scalar normalization, update/upsert responses can return different PHP types (e.g. strings instead of ints/bools/floats) than the other CRUD methods.

You should either:

  • Add casting($context, …) before each decode(...) in these three flows, or
  • Explicitly document that only create/get/find responses are guaranteed to be cast, and keep adapter behavior consistent.

Also applies to: 6048-6053, 6796-6800


6565-6567: upsertDocumentsWithIncrease: context‑aware decode only on non‑operator batches

The introduction of a shared QueryContext and conditional decode($context, $doc) when $hasOperators === false makes sense: operator‑based upserts refetch documents via refetchDocuments(), and those refetched copies will go through the normal find/get decode pipeline.

Once you add casting($context, …) consistently to update/upsert flows (see earlier comment), mirror it here as well so non‑operator upsert results follow the same type normalization path as create/get/find.

Also applies to: 6796-6800


7990-8006: count(): context + joins + relationship filters are wired well; clarify $max semantics

Positives:

  • Using QueryContext and DocumentsValidator for count() aligns its query semantics with find().
  • Auth checks are properly enforced across all collections in the context, with addSkipAuth() mirroring find().
  • convertQueries($context, $queries) and convertRelationshipFiltersToSubqueries() ensure relationship filters are normalized before reaching the adapter, and adapter->count($context, $max, $filters, $joins) is a sensible new adapter contract.

However, you now reject $max < 1 in count() while sum():

  • Still documents $max=0 as “unlimited count,” and
  • Forwards $max directly to the adapter without validation.

This mismatch can confuse callers; decide on a single semantic (e.g. “$max must be null or >=1; null means unlimited”) and apply it consistently to both count() and sum(), updating both docblocks and adapter usage.

Also applies to: 8016-8025, 8031-8038, 8049-8067


8086-8087: sum(): context & relationship filters OK, but $max handling still inconsistent with count() and docblock

  • Introducing QueryContext + DocumentsValidator and running convertQueries() plus convertRelationshipFiltersToSubqueries() before calling adapter->sum(...) is symmetric with the new find()/count() flows, which is good.
  • Auth logic ($skipAuth vs authorization->skip) remains the same, so behavior is preserved.

But as noted above, sum()’s docblock still says “Pass $max=0 for unlimited count,” while:

  • sum() does not treat 0 specially, and
  • count() now rejects $max < 1.

Please align sum()’s $max semantics and documentation with count() (or vice‑versa) so callers have a single, well‑defined contract.

Also applies to: 8088-8091, 8095-8103, 8121-8133


8710-8789: Remove unused $idAdded variable destructuring at line 8785

The $idAdded variable is captured from QueryContext::addSelect() but never used. Update the destructuring to capture only $queries:

if (!empty($relationships)) {
    [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
}

This eliminates the PHPMD warning and ensures $id is auto-selected only when relationships exist, as intended.

🧹 Nitpick comments (7)
src/Database/Database.php (7)

4295-4331: getDocument(): SELECT‑only contract and context‑aware decoding

  • Enforcing that $queries contain only Query::select(...) and validating them via DocumentsValidator plus QueryContext is coherent with using the new projection/join machinery.
  • Auto‑adding $permissions via QueryContext::addSelect() only when user already supplies selects, then stripping it back out via $permissionsAdded preserves both correctness (auth checks) and API expectations (projection results).

Consider updating the PHPDoc for getDocument() to explicitly say: only SELECT queries are allowed and others will result in QueryException, to make this behavioral constraint obvious to callers.

Also applies to: 4361-4363, 4387-4388, 4411-4413


4513-4516: Nested relationship select handling in populateDocumentsRelationships

Using processRelationshipQueries() to derive $nestedSelections, then threading those down the BFS queue (and re‑invoking processRelationshipQueries() per level) is a reasonable way to support dotted relationship selects while keeping operator logic centralized.

One nuance: the $selects value returned by processRelationshipQueries() in the inner call is discarded (only $nextSelects is used). This is fine today because relationship fetch helpers don’t need per‑depth SELECTs beyond what applySelectFiltersToDocuments() does after grouping, but if you later want tighter projection at each depth, you may want to start honoring that first tuple element there.

Also applies to: 4532-4545


4988-5003: applySelectFiltersToDocuments: logic is sound, but keep filters in sync with decode/casting

Post‑filtering relationship batches by building an attributesToKeep map from select queries, honoring *, and always preserving internal $ attributes (both from INTERNAL_ATTRIBUTES and by leading $) is correct and efficient.

Just ensure that any future changes to INTERNAL_ATTRIBUTES or decode/casting behavior are mirrored here so internal/system attributes aren’t accidentally dropped from nested relationship documents.

Also applies to: 5005-5012


5879-5893: Bulk update: validator/context wiring is good; required $permissions/$sequence selects are sharp edges

Positives:

  • Building a QueryContext and validating $queries with DocumentsValidator is consistent with find() / count() / sum().
  • The late decode via decode($context, $doc) on each batch element is coherent with the new context‑aware decoding pipeline.

However, the new guard:

foreach (['$permissions', '$sequence'] as $required) {
    if (!$batch[0]->offsetExists($required)) {
        throw new QueryException("Missing required attribute {$required} in select query");
    }
}

forces callers who use custom SELECTs in find() for bulk update to remember to include both $permissions and $sequence, but bulk paths themselves don’t auto‑inject those system attributes (unlike how getDocument() now auto‑injects $permissions when selects are present).

Consider either:

  • Automatically adding Query::select('$permissions', system: true) and Query::select('$sequence', system: true) for bulk update flows when any user SELECTs are present, or
  • Clearly documenting this requirement in the public API docs for updateDocuments() so consumers aren’t surprised by runtime QueryExceptions.

Also applies to: 5974-5981, 6048-6053


8240-8327: decode(QueryContext,…): alias‑aware decoding looks correct; tighten exception type

The new context‑aware decode() implementation:

  • Builds an internal+schema map per collection (filtered via adapter->filter) and iterates result keys, mapping them back to:
    • The right collection (using alias resolution from $selects and QueryContext), and
    • The right attribute definition (internal vs schema).
  • Falls back to schemaless behavior when adapter doesn’t support attributes, by copying unknown fields directly to the output document.
  • Applies decode filters in reverse order per attribute and writes canonical attribute IDs (or AS names) into a fresh document instance for the main collection.

Logic is sound overall. Two small suggestions:

  • You currently throw a generic \Exception('Invalid query: Unknown Alias context') on bad aliases; prefer throwing QueryException for consistency with other query‑validation paths.
  • If you later add join‑result projection that surfaces non‑main‑collection attributes, consider whether those should be nested or flattened; right now everything goes onto the main collection’s Document, which is fine, but should be consciously documented.

8651-8684: getCacheKeys(): projection‑aware cache keying looks correct

Extending getCacheKeys() to accept $selects and include md5(serialize($selects)) in the document hash key when projections are present ensures that different select lists don’t collide in the cache. This is important now that per‑field selects are first‑class.

Just be aware that serialize($selects) includes full Query objects; if their internal representation ever grows, you may want a more compact, stable representation (e.g., normalized attribute+alias list) for the hash.

Also applies to: 8675-8677


8807-8954: convertRelationshipFiltersToSubqueries/processNestedRelationshipPath: multi‑level filter lowering looks correct but could be expensive

The new relationship filter lowering logic:

  • Groups dot‑path filters by top‑level relationship key.
  • For each group, builds equivalent queries against the related collection (using Query::parseQuery()), optionally handling multi‑level paths via processNestedRelationshipPath().
  • For “virtual parent” sides (ONE_TO_MANY parent, MANY_TO_ONE child, MANY_TO_MANY), walks from leaf collections back up using twoWayKey and junction collections to derive parent IDs.
  • Replaces the original dot‑path filters with either:
    • Query::equal('$id', $parentIds) for virtual parents, or
    • Query::equal($relationshipKey, $matchingIds) for direct sides.
  • Returns null early when no matches are possible, letting callers short‑circuit to empty results.

This is a solid, general approach to translating relationship filters into SQL‑safe subqueries. The main trade‑off is potential extra find() calls per relationship group, especially with deep nesting and large fan‑outs.

If you see performance issues under heavy relational filtering, consider:

  • Caching intermediate ID sets per path within a single top‑level request, and/or
  • Bounding the size of PHP_INT_MAX‑style subqueries for pathological cases.

Functionally, though, the implementation looks consistent with the rest of the relationship system.

Also applies to: 8978-9181

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a732ab4 and 74e9df8.

📒 Files selected for processing (1)
  • src/Database/Database.php
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
🧬 Code graph analysis (1)
src/Database/Database.php (2)
src/Database/Validator/Queries/V2.php (2)
  • V2 (23-714)
  • getDescription (135-138)
src/Database/QueryContext.php (8)
  • QueryContext (8-136)
  • add (68-76)
  • addSelect (102-135)
  • getCollections (32-35)
  • skipAuth (83-94)
  • addSkipAuth (78-81)
  • getMainCollection (40-43)
  • getCollectionByAlias (45-63)
🪛 PHPMD (2.15.0)
src/Database/Database.php

8785-8785: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (MariaDB)
🔇 Additional comments (6)
src/Database/Database.php (6)

34-36: Switch to V2 DocumentsValidator looks appropriate

Aliasing Utopia\Database\Validator\Queries\V2 as DocumentsValidator is clear and consistent with its new role across the file. No issues here.


5118-5123: createDocument/createDocuments: consistent casting + decode path

Threading a QueryContext through createDocument() and createDocuments(), then applying $this->casting($context, ...) followed by $this->decode($context, ...) after adapter->castingAfter() keeps type normalization and filter decoding consistent with getDocument() / find().

This alignment across create/read paths is good; no issues spotted.

Also applies to: 5171-5173, 5234-5238


6141-6170: Relationship maintenance: minimal $id projections on nested reads look correct

The various relationship maintenance paths in updateDocumentRelationships() now wrap nested getDocument() / findOne() and find() calls with Query::select('$id') where only the ID is needed. That’s a good optimization and keeps the new V2 query validator happy while minimizing projection size.

These call‑sites all preserve existing behavior while cutting payload; they look consistent across one‑to‑one, one‑to‑many, many‑to‑one, and many‑to‑many branches.

Also applies to: 6181-6184, 6201-6205, 6283-6284, 6296-6299, 6325-6327, 6337-6338, 6407-6412


6853-6855: New $id‑only selects in increase/decrease/delete relationship helpers

Adding Query::select('$id') (and for some cases key attributes) to the internal find() / findOne() / getDocument() calls used by increase/decrease and delete relationship helpers is consistent with the new V2 validator and reduces data transferred for these internal maintenance queries.

These changes look safe and keep behavior while tightening projections.

Also applies to: 7231-7233, 7254-7256, 7292-7293, 7340-7343, 7363-7366, 7433-7436, 7553-7559


7750-7771: find(): QueryContext, joins, and adapter contract look coherent

Key points in the updated find():

  • Correctly building QueryContext with the main collection plus all join targets from Query::getJoinQueries($queries).
  • Per‑collection auth: iterating context->getCollections(), checking read/update/delete permission via Input($forPermission, ...), and storing skipAuth flags in context for the adapter.
  • Switching to $this->convertQueries($context, $queries) fixes the prior self::convertQueries instance‑method bug and lets conversion access the right alias/collection mapping.
  • Using processRelationshipQueries() to split top‑level relationship selects from nested selects and auto‑inject $id only when relationships exist.
  • Feeding the adapter a richer call:
$this->adapter->find(
    $context,
    $limit ?? 25,
    $offset ?? 0,
    $cursor,
    $cursorDirection,
    $forPermission,
    selects: $selects,
    filters: $filters,
    joins: $joins,
    vectors: $vectors,
    orderQueries: $orders
);

is a clean contract for join‑ and projection‑aware backends.

The overall flow is solid; no correctness issues obvious here beyond the bulk update/delete SELECT constraints already called out.

Also applies to: 7795-7806, 7841-7845, 7852-7865, 7874-7878


8547-8561: convertQueries/convertQuery: context‑aware conversion and datetime handling look good

  • Accepting QueryContext and recursing through nested/join queries while re‑invoking convertQueries($context, $query->getValues()) is a clean way to propagate alias/collection semantics down the tree.
  • In convertQuery(), cloning the collection referenced by getAlias(), resolving attribute metadata (including internal attributes), and mutating the query with setOnArray() and setAttributeType() preserves existing behavior.
  • Datetime normalization honors adapter->getSupportForUTCCasting() and falls back to DateTime::setTimezone, wrapped in QueryException on failure.

The logic is solid; no issues beyond the already‑fixed static/instance call site in find().

Also applies to: 8566-8615

Copy link
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Database.php (1)

8890-9037: Nested many‑to‑many relationship filters likely never match due to twoWayKey resolution logic.

In processNestedRelationshipPath() and convertRelationshipFiltersToSubqueries() the reverse lookup for parent IDs uses:

  • find($link['toCollection'], …) with skipRelationships() (Lines 8968–8988).
  • Extraction of parent IDs via $doc->getAttribute($link['twoWayKey']) (Lines 8993–9013 and 9208–9230).

For many‑to‑many relationships, the twoWayKey column exists on the junction collection, not on the related collection documents themselves; and with skipRelationships() enabled in processNestedRelationshipPath(), relationship attributes won’t be populated either. That means for multi‑level paths involving a many‑to‑many hop (e.g. project.employees.skills.name), parentIds will stay empty and these helpers will return null, causing find()/count()/sum() to treat valid filters as “no matches”.

You’ll likely need a special‑case for many‑to‑many in the nested path logic:

  • Either walk through the junction via getJunctionCollection() (mirroring populateManyToManyRelationshipsBatch()), or
  • Avoid skipRelationships() on many‑to‑many and derive parent IDs from populated relationship attributes, as in the existing non‑nested filtering behavior.

Also applies to: 9061-9264

♻️ Duplicate comments (1)
src/Database/Database.php (1)

5818-5822: Align update/upsert return casting with create/get/find to avoid type inconsistencies.

createDocument(), createDocuments(), getDocument(), and find() all call casting($context, …) followed by decode($context, …) before returning documents, but:

  • updateDocument() only calls decode($context, $document) (Lines 5818–5822).
  • updateDocuments() only calls decode($context, $doc) inside the batch loop (Lines 6048–6053).
  • upsertDocumentsWithIncrease() only calls decode($context, $doc) when !$hasOperators (Lines 6796–6800).

For adapters relying on Database::casting() for scalar normalization, these paths will return different PHP types (string vs int/bool/float) than the other CRUD methods using the same collection and queries, which is a behavioral inconsistency.

Consider also invoking casting($context, …) in these paths before decode() to keep the return shape uniform across read/write operations.

Suggested diff (conceptual)
@@ public function updateDocument(…)
-        $context = new QueryContext();
-        $context->add($collection);
-
-        $document = $this->decode($context, $document);
+        $context = new QueryContext();
+        $context->add($collection);
+
+        $document = $this->casting($context, $document);
+        $document = $this->decode($context, $document);
@@ public function updateDocuments(…)
-                $doc = $this->adapter->castingAfter($collection, $doc);
-                $doc->removeAttribute('$skipPermissionsUpdate');
-                $this->purgeCachedDocument($collection->getId(), $doc->getId());
-                $doc = $this->decode($context, $doc);
+                $doc = $this->adapter->castingAfter($collection, $doc);
+                $doc->removeAttribute('$skipPermissionsUpdate');
+                $this->purgeCachedDocument($collection->getId(), $doc->getId());
+                $doc = $this->casting($context, $doc);
+                $doc = $this->decode($context, $doc);
@@ public function upsertDocumentsWithIncrease(…)
-                $doc = $this->adapter->castingAfter($collection, $doc);
-                if (!$hasOperators) {
-                    $doc = $this->decode($context, $doc);
-                }
+                $doc = $this->adapter->castingAfter($collection, $doc);
+                if (!$hasOperators) {
+                    $doc = $this->casting($context, $doc);
+                    $doc = $this->decode($context, $doc);
+                }

Also applies to: 6048-6053, 6565-6567, 6796-6800

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

8864-8869: Drop unused $idAdded in processRelationshipQueries() and keep $id auto‑select relationship‑only.

PHPMD correctly flags $idAdded as unused here:

if (!empty($relationships)) {
    [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
}

You only care about the updated $queries, and the intent (per prior guidance) is to auto‑inject $id only when relationship attributes exist.

You can simplify and satisfy static analysis by discarding the second tuple element:

Proposed change
-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
+        /**
+         * In order to populateDocumentsRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }

This preserves behavior and removes the PHPMD warning.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 74e9df8 and 0c78f5b.

📒 Files selected for processing (1)
  • src/Database/Database.php
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
🧬 Code graph analysis (1)
src/Database/Database.php (2)
src/Database/Validator/Queries/V2.php (3)
  • V2 (23-714)
  • isEmpty (163-174)
  • getDescription (135-138)
src/Database/QueryContext.php (8)
  • QueryContext (8-136)
  • add (68-76)
  • addSelect (102-135)
  • getCollections (32-35)
  • skipAuth (83-94)
  • addSkipAuth (78-81)
  • getCollectionByAlias (45-63)
  • getMainCollection (40-43)
🪛 GitHub Actions: Linter
src/Database/Database.php

[error] 1-1: PSR-12: statement_indentation violation detected by Pint lint.

🪛 PHPMD (2.15.0)
src/Database/Database.php

8868-8868: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (Postgres)

Copy link
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Database/Database.php (1)

4980-5013: Nested select paths currently drop intermediate relationship attributes (breaks multi-level population)

applySelectFiltersToDocuments() builds $attributesToKeep from Query::getAttribute() verbatim. For nested paths like comments.author.name, the top-level related documents (e.g. comments) only have an author attribute, not author.name. As a result, when you call this helper from the batch population methods, the author attribute is removed, so deeper relationships (e.g. author → company) never get populated.

To keep multi-level selections working, this method should treat only the top-level segment of the select path as the attribute to keep.

Proposed fix: normalize dotted paths to their top-level segment
     // Collect all attributes to keep from select queries
-    $attributesToKeep = [];
-
-    foreach ($selectQueries as $selectQuery) {
-        $attributesToKeep[$selectQuery->getAttribute()] = true;
-    }
-
-    // Early return if wildcard selector present
-    if (isset($attributesToKeep['*'])) {
-        return;
-    }
+    $attributesToKeep = [];
+
+    foreach ($selectQueries as $selectQuery) {
+        $attribute = $selectQuery->getAttribute();
+
+        // Wildcard: keep everything
+        if ($attribute === '*') {
+            $attributesToKeep['*'] = true;
+            break;
+        }
+
+        // For dotted paths like "author.name", keep the top-level key "author"
+        $topLevel = \explode('.', $attribute)[0];
+        $attributesToKeep[$topLevel] = true;
+    }
+
+    // Early return if wildcard selector present
+    if (isset($attributesToKeep['*'])) {
+        return;
+    }

This preserves:

  • Simple selects (e.g. select('name') → keep name),
  • Nested selects (e.g. select('author.name') → keep author on this level and let deeper traversal handle the inner name), and
  • Wildcard behavior.
♻️ Duplicate comments (3)
src/Database/Database.php (3)

5818-5822: Update/upsert return paths still skip casting() (inconsistent with create/get/find)

updateDocument(), updateDocuments(), and upsertDocumentsWithIncrease() now create a QueryContext and call decode($context, ...) on the documents they return, but they never pass those documents through casting($context, ...) the way createDocument(), createDocuments(), getDocument(), and find() do.

If Database::casting() is responsible for normalizing scalar types (bool/int/float/id) for adapters that don’t fully cast on their own, these methods can still return un-normalized field values while the other CRUD methods return normalized ones.

Consider aligning the pipelines by inserting casting($context, $document, $selects) (or without $selects where appropriate) before decode() in these methods, mirroring createDocument()/find(). Please verify current adapter semantics for getSupportForCasting() and castingAfter() to decide whether this is required for the SQL/Mongo adapters you care about.

Also applies to: 5879-5881, 6052-6053, 6565-6567, 6799-6800


7992-8003: Count/sum: semantics of $max are still inconsistent

count() now explicitly rejects $max < 1, while sum()’s docblock still advertises $max = 0 as “unlimited count” and forwards $max straight to the adapter. This leaves the old inconsistency in place: callers can’t use $max = 0 with count(), and adapters may treat $max = 0 for sum() as LIMIT 0 rather than “no limit”.

It would be good to decide on one set of semantics (e.g. “$max must be null or > 0; null means unlimited”) and align both methods’ validation, adapter calls, and docblocks accordingly.

Also applies to: 8086-8087


8863-8869: Remove unused $idAdded in processRelationshipQueries() and ignore merge flag

processRelationshipQueries() now uses QueryContext::addSelect() to auto-add $id when relationships exist, but still captures the unused $idAdded flag. This matches the PHPMD warning and the prior guidance for this method.

You can drop the unused variable and only capture the updated queries:

-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
+        /**
+         * In order to populateDocumentsRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }

This preserves behavior (only auto-adding $id when there are relationship attributes) and satisfies PHPMD.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0c78f5b and ddf2e19.

📒 Files selected for processing (1)
  • src/Database/Database.php
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
🧬 Code graph analysis (1)
src/Database/Database.php (2)
src/Database/Validator/Queries/V2.php (2)
  • V2 (23-714)
  • getDescription (135-138)
src/Database/QueryContext.php (8)
  • QueryContext (8-136)
  • add (68-76)
  • addSelect (102-135)
  • getCollections (32-35)
  • skipAuth (83-94)
  • addSkipAuth (78-81)
  • getCollectionByAlias (45-63)
  • getMainCollection (40-43)
🪛 PHPMD (2.15.0)
src/Database/Database.php

8868-8868: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

🔇 Additional comments (8)
src/Database/Database.php (8)

4306-4310: Context-aware DocumentsValidator wiring looks consistent across entry points

Using Queries\V2 as DocumentsValidator with QueryContext (in getDocument(), updateDocuments(), deleteDocuments(), find(), count(), and sum()) matches the new context-based design and parameter contract (id type, max values, date bounds, attribute support, UID length). The constructor usage looks type-safe and consistent.

Please just double-check against the actual Queries\V2 signature that the omitted optional arguments (e.g. maxQueriesCount, maxLimit, maxOffset) rely on defaults you’re happy with for all these call sites.

Also applies to: 5885-5893, 7526-7534, 7776-7784, 8030-8038, 8095-8103


4295-4304: Confirm getDocument()’s “select-only” API and addSelect() semantics

Limiting $queries in getDocument() to pure TYPE_SELECT (and throwing if any other method appears) is a reasonable tightening, but it’s a behavioral change if callers previously passed non‑select queries (even if they were ignored). Also, QueryContext::addSelect($selects, Query::select('$permissions', system: true)) must not inject a $permissions select when there were no explicit selects originally, otherwise getDocument() would only fetch $permissions (and whatever system selects you add later) instead of “all attributes”.

Can you confirm:

  • That existing public usage of getDocument() never relied on non‑select queries, and
  • That QueryContext::addSelect() is a no‑op when $selects is empty (so the “no selects == all attributes” behavior is preserved)?

Also applies to: 4321-4324


4327-4331: Select-aware cache keying looks good; verify purge clears all variants

Including $selects in the document hash key via md5(serialize($selects)) is a nice way to distinguish different projections. purgeCachedDocumentInternal() still purges by the base $documentKey, which should remove all select-variants as long as the underlying Cache implementation treats purge($documentKey) as “delete all subkeys”.

Please confirm that:

  • Cache::load/save/purge interpret the third parameter as a subkey and that purge($documentKey) clears all hashes for that key, and
  • There is no path where purgeCachedDocumentInternal() would need to know the select set explicitly to invalidate a specific variant.

Also applies to: 7691-7701, 8731-8767


5118-5123: QueryContext-based casting/decoding in create paths looks correct

Using a fresh QueryContext (with only the main collection) for createDocument() and createDocuments() and then running casting($context, ...) followed by decode($context, ...) aligns these write paths with the new read/join pipeline and keeps type normalization/decoding consistent.

Also applies to: 5171-5173, 5235-5238


7750-7771: Join-aware find() flow (context, auth, conversion, adapter call) is well-structured

The updated find():

  • Builds a QueryContext with the main and joined collections and records per-collection skip-auth flags.
  • Validates queries via DocumentsValidator using that context.
  • Runs convertQueries($context, …) so alias-based attributes get proper type/array hints.
  • Processes relationship selects via processRelationshipQueries() and converts relationship filters with convertRelationshipFiltersToSubqueries().
  • Delegates to adapter->find($context, … selects, filters, joins, vectors, orderQueries) and then applies casting($context, …, $selects) and decode($context, …, $selects).

This is a coherent, join-capable pipeline that keeps authorization and query conversion centralized.

Given the new Adapter::find signature, please confirm all adapters (SQL, Mongo, etc.) are updated to accept the QueryContext and the split selects/filters/joins/vectors/orderQueries contract.

Also applies to: 7795-7806, 7841-7842, 7853-7865, 7875-7878


8234-8241: New context-aware decode()/casting() implementations look coherent for joins

The reworked decode(QueryContext $context, Document $document, array $selects = []) and casting(QueryContext $context, Document $document, array $selects = []):

  • Build per-collection schema maps keyed by adapter-filtered attribute names.
  • Resolve aliasing (AS and per-join alias) from $selects to map keys back to the right collection/attribute.
  • Respect internal attributes and schemaless operation when getSupportForAttributes() === false.
  • In casting(), construct a new document of the main collection type with correctly cast scalars, while decode() decorates the existing document with filter-decoded values.

This design matches the new join/query-context model and should behave correctly both for single-collection and multi-collection queries.

Also applies to: 8257-8299, 8314-8317, 8421-8429, 8443-8447, 8480-8485, 8523-8526


8630-8644: convertQueries() / convertQuery() refactor to use QueryContext looks sound

Switching convertQueries()/convertQuery() to accept a QueryContext and resolving attributes via alias:

  • Properly handles nested and join queries by recursing on isNested()/isJoin() values and reusing the same context.
  • Ensures non-SELECT queries get their onArray, attribute type, and datetime normalization set based on the aliased collection’s schema, while SELECTs pass through untouched.
  • Throws a QueryException on unknown aliases instead of silently mis-interpreting attributes.

This should make attribute-type handling for joins more robust without changing behavior for single-collection queries.

Also applies to: 8649-8697


8890-9037: Multi-level relationship filter → subquery conversion appears correct but is complex

processNestedRelationshipPath() plus convertRelationshipFiltersToSubqueries() introduce a fairly sophisticated pipeline:

  • Parse dotted attributes into relationship chains.
  • Walk from the deepest collection back to the start, resolving parent IDs via $twoWayKey (and for many-to-many via populated relationship attributes).
  • Replace filters like author.company.name = 'X' with equivalent $id/relationship-key filters on the starting collection, returning null early when no matches are possible.

The overall structure looks logically consistent for oneToOne, oneToMany, manyToOne, and manyToMany, and the early-return null behavior is a good optimization for impossible filters.

Given the number of relation-type/side combinations, it would be worth validating this against your e2e relationship tests (especially multi-hop cases like project.employee.company.* and many-to-many chains) to ensure every combination yields the expected ID sets.

Also applies to: 9061-9264

Copy link
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: 0

♻️ Duplicate comments (4)
src/Database/Database.php (4)

5974-5981: Bulk update/delete still hard-require $permissions & $sequence in selects without auto-injection

Both updateDocuments() and deleteDocuments() enforce that the first batch document contains $permissions and $sequence, and throw QueryException if those system attributes are missing from the select projection. Since find() does not automatically add these system selects (unlike how $id is auto-added for relationships and $permissions is injected in getDocument()), callers must remember to always include both fields whenever they customize selects for bulk ops. That’s easy to miss and somewhat asymmetric. Consider either auto-injecting these system selects in the bulk paths (similar to $id injection in processRelationshipQueries() and $permissions in getDocument()), or clearly documenting this requirement on the updateDocuments() / deleteDocuments() PHPDocs.

Also applies to: 7581-7588


7992-8067: count() vs sum() $max semantics remain inconsistent

count() now validates $max and rejects values < 1, which is clear and safe. sum() still accepts $max unchecked and the docblock claims “$max=0 for unlimited count”, but the underlying adapter is likely to treat 0 as LIMIT 0, not “unlimited”. This mismatch between documentation and behavior (and between count() and sum()) can surprise callers. It would be better to standardize semantics (e.g. “null means no limit, $max must be > 0”) across both methods and update docblocks and adapter plumbing accordingly.

Also applies to: 8086-8137


5818-5822: Update / bulk-update / upsert paths still skip casting() → possible type-normalization inconsistency

These write flows now call decode($context, …) on the returned documents but still don’t invoke casting($context, …) (unlike createDocument(), createDocuments() batch, getDocument(), and find(), which call casting() before decode()). For adapters that rely on Database::casting() for scalar normalization (string → int/bool/float based on schema), this can leave updateDocument(), updateDocuments() callbacks, and upsertDocumentsWithIncrease() callbacks returning different PHP types for the same attributes compared to create/get/find. Unless the adapter’s castingAfter() already guarantees identical normalization, consider aligning these paths by adding casting($context, …) before decode() in the post-write section of each method.

// Example pattern (updateDocument tail)
$context = new QueryContext();
$context->add($collection);

// Optionally normalize scalars before decode, matching other CRUD flows
$document = $this->casting($context, $document);
$document = $this->decode($context, $document);

Also applies to: 6048-6053, 6565-6567, 6796-6800


8800-8875: Remove unused $idAdded in processRelationshipQueries() and keep $id auto-select relationship-only

PHPMD is right here: $idAdded from QueryContext::addSelect() is never used—only the updated $queries matter. Given the intended behavior (“auto-add $id only when the collection has relationship attributes”), you can safely drop the second tuple element and avoid the unused-local warning while preserving semantics. This also aligns with the prior guidance captured in the learnings for this method.

Proposed fix
-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
+        /**
+         * In order to populateDocumentsRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }
🧹 Nitpick comments (1)
src/Database/Database.php (1)

8240-8325: decode() now mutates in-place while casting() builds a new instance—watch for mixed-key state

The new decode(QueryContext, …) implementation decodes directly into the original $document, while casting() still creates a fresh Document and copies attributes. This means a casted-then-decoded flow (e.g. find() / getDocument()) will end up with a new Document from casting() that decode() then mutates in place, potentially leaving both canonical keys and any pre-existing raw keys present. That’s probably fine, but if you ever rely on getArrayCopy() shape (e.g. for serialization or equality checks), consider either (a) having decode() also build a new Document like casting(), or (b) explicitly unsetting raw keys when $attributeKey !== $key.

Also applies to: 8425-8533

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0c78f5b and c334bf4.

📒 Files selected for processing (1)
  • src/Database/Database.php
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
🧬 Code graph analysis (1)
src/Database/Database.php (3)
src/Database/QueryContext.php (8)
  • QueryContext (8-136)
  • add (68-76)
  • addSelect (102-135)
  • getCollections (32-35)
  • skipAuth (83-94)
  • addSkipAuth (78-81)
  • getCollectionByAlias (45-63)
  • getMainCollection (40-43)
src/Database/Document.php (5)
  • getAttribute (224-231)
  • getId (63-66)
  • Document (12-470)
  • getCollection (85-88)
  • setAttribute (244-261)
src/Database/Validator/Authorization/Input.php (1)
  • Input (5-49)
🪛 PHPMD (2.15.0)
src/Database/Database.php

8872-8872: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Unit Test
🔇 Additional comments (2)
src/Database/Database.php (2)

8894-9268: Complex nested relationship-filter conversion has existing test coverage

The review recommended adding targeted tests for depth-2 and depth-3 nested paths (like project.employee.company.name), null short-circuit cases, and mixed relationship-with-non-relationship filters. However, these tests already exist in tests/e2e/Adapter/Scopes/RelationshipTests.php and comprehensively cover:

  • Depth-2 paths: employee.company.name, employee.department.name, project.employee.name
  • Depth-3 paths: project.employee.company.name, project.employee.department.budget, project.employee.company.industry
  • Mixed filters: non-relationship attributes combined with nested relationship queries
  • Empty-result cases: queries correctly returning zero matches when no documents satisfy the filters

The logic for processNestedRelationshipPath() and convertRelationshipFiltersToSubqueries() is appropriately tested across relationship types and nesting depths.

Likely an incorrect or invalid review comment.


4295-4335: Remove this review comment—the SELECT-only constraint is intentional and well-tested.

The new getDocument() validation that rejects non-SELECT queries is not a breaking change. The codebase has been written to comply with this constraint: all internal calls pass SELECT queries only (e.g., lines 6140, 6169, 6283, 6326, 6407), and there is a dedicated test method (testGetDocumentOnlySelectQueries at lines 1477–1510 in DocumentTests.php) that explicitly validates this behavior. No scan is needed.

Likely an incorrect or invalid review comment.

Copy link
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Database/Database.php (2)

4387-4394: Relationship population is disabled for collections without explicit nested selections

In both getDocument() (lines 4387–4394) and find() (lines 7868–7872), relationship population is gated by:

if (
    !$this->inBatchRelationshipPopulation &&
    $this->resolveRelationships &&
    !empty($relationships) &&
    (empty($selects) || !empty($nestedSelections))
) {
    // populateDocumentsRelationships(...)
}

However, before this check, both methods call processRelationshipQueries(), which injects a $id system select whenever the collection has relationships:

if (!empty($relationships)) {
    [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
}

This means when a caller invokes getDocument() or find() without explicit select queries on a relationship-bearing collection, $selects becomes non-empty (containing the injected $id). The gate condition then evaluates to:

  • (FALSE || !empty($nestedSelections)) when no nested selections were requested
  • Result: relationships are not populated unless the caller explicitly nested-selected them

This changes the historical behavior where relationships were auto-resolved by default. The gate should use a flag tracking user-specified selects (before system injection) rather than the post-injection $selects array. Consider preserving the original $selects or adding an $hasExplicitSelects flag before calling processRelationshipQueries().

Also applies to: 7868-7872


8801-8947: Nested-path relationship filtering (3+ levels) fails for virtual relationships due to skipRelationships preventing population

The review correctly identifies a twoWayKey population issue but misattributes it. The actual problem:

Single-level many-to-many filtering works correctly. convertRelationshipFiltersToSubqueries() at lines 9032–9034 calls find() without skipRelationships() and without explicit SELECT queries, so the guard (empty($selects) || !empty($nestedSelections)) at line 7868 is satisfied, and populateDocumentsRelationships() is invoked to populate the twoWayKey.

Nested-path filtering breaks for all virtual relationships. processNestedRelationshipPath() at lines 8869, 8881, and 8904 wraps all internal find() calls in skipRelationships(), which sets $this->resolveRelationships = false. This causes the guard at line 7868 to fail, so populateDocumentsRelationships() is never invoked and the twoWayKey remains null for deep paths like project.employee.company.name.

This affects:

  • Many-to-many nested paths
  • One-to-many relationships queried from parent side
  • Many-to-one relationships queried from child side

Tests cover only single-level filters (e.g., assignedProjects.priority). Multi-level nested paths have no test coverage and will fail when attempting to read the twoWayKey.

The fix requires either allowing relationship population in processNestedRelationshipPath() or resolving parent IDs via direct junction collection queries instead of relying on the twoWayKey attribute.

♻️ Duplicate comments (1)
src/Database/Database.php (1)

8707-8783: Drop unused $idAdded from processRelationshipQueries() to satisfy PHPMD and simplify intent

PHPMD correctly reports $idAdded as unused in processRelationshipQueries():

if (!empty($relationships)) {
    [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
}

Only the updated $queries are used; the boolean flag is ignored. You can keep behavior and fix the warning by discarding the second tuple element:

-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
+        if (!empty($relationships)) {
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }

This also aligns with the previously agreed guidance to add $id only when there are relationship attributes and to avoid unused locals in this helper.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c334bf4 and 69d7013.

📒 Files selected for processing (1)
  • src/Database/Database.php
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
🧬 Code graph analysis (1)
src/Database/Database.php (2)
src/Database/Validator/Queries/V2.php (1)
  • V2 (23-714)
src/Database/QueryContext.php (8)
  • QueryContext (8-136)
  • add (68-76)
  • addSelect (102-135)
  • getCollections (32-35)
  • skipAuth (83-94)
  • addSkipAuth (78-81)
  • getCollectionByAlias (45-63)
  • getMainCollection (40-43)
🪛 PHPMD (2.15.0)
src/Database/Database.php

8779-8779: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (MySQL)
🔇 Additional comments (1)
src/Database/Database.php (1)

4305-4314: Context‑aware DocumentsValidator wiring across CRUD/find/count/sum looks consistent

The move to use DocumentsValidator (Queries V2) with QueryContext—in getDocument(), updateDocuments(), deleteDocuments(), find(), count(), and sum()—is wired consistently:

  • Each method builds a QueryContext, adds the main collection and any join collections, and then constructs DocumentsValidator with the same context and adapter limits (id type, max values, date range, max UID length, supportForAttributes).
  • Validation is run before query grouping / conversion, which matches the expected flow for guarding invalid queries early.

No issues here; the context propagation and validator usage look correct.

Also applies to: 5884-5893, 7525-7534, 7776-7784, 8030-8038, 8095-8103

Copy link
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/Database/Database.php (3)

8190-8207: Fix stale PHPStan suppression causing pipeline failure in encode()

The @phpstan-ignore-next-line on the is_null($value) check is now reported as “No error to ignore” in the pipeline, so it’s actively breaking builds without providing value. The logic itself is fine; only the suppression is problematic.

You can safely drop the suppression (and the accompanying comment, if you like) to clear the error without changing behavior:

Suggested change
-        // Assign default only if no value provided
-        // False positive "Call to function is_null() with mixed will always evaluate to false"
-        // @phpstan-ignore-next-line
-        if (is_null($value) && !is_null($default)) {
+        // Assign default only if no value provided
+        if (is_null($value) && !is_null($default)) {
             // Skip applying defaults during updates to avoid resetting unspecified attributes
             if (!$applyDefaults) {
                 continue;
             }

8240-8440: Use QueryException instead of generic \Exception for unknown alias contexts in decode()/casting()

Both decode() and casting() throw a generic \Exception('Invalid query: Unknown Alias context') when QueryContext::getCollectionByAlias() returns an empty collection. Elsewhere (e.g. convertQuery()) the same condition is treated as a QueryException, which is more appropriate for user-facing query errors and keeps error handling consistent.

Consider switching these throws to QueryException:

Suggested change in decode() and casting()
-            $collection = $context->getCollectionByAlias($alias);
-            if ($collection->isEmpty()) {
-                throw new \Exception('Invalid query: Unknown Alias context');
-            }
+            $collection = $context->getCollectionByAlias($alias);
+            if ($collection->isEmpty()) {
+                throw new QueryException('Invalid query: Unknown Alias context');
+            }

This keeps all “bad query / bad alias” paths surfaced as query errors rather than generic 500s.


8801-9175: Many-to-many relationship filters fail to populate the two-way relationship key, causing all filters to return no matches

When filtering on many-to-many relationships (e.g., Query::equal('tags.name', ['foo'])), the code calls find() on the related collection without explicit SELECT queries:

if ($needsParentResolution) {
    $matchingDocs = $this->silent(fn () => $this->find(
        $relatedCollection,
        \array_merge($relatedQueries, [
            Query::limit(PHP_INT_MAX),
        ])
    ));
}

This path has two critical problems:

  1. Missing relationship population: find() internally calls processRelationshipQueries(), which auto-injects a system-only $id select whenever relationships exist. This makes $selects non-empty, so the population condition (empty($selects) || !empty($nestedSelections)) becomes false, and populateDocumentsRelationships() is never called.

  2. Unpopulated $twoWayKey: For many-to-many, the $twoWayKey attribute is a relationship that only gets populated via populateDocumentsRelationships(). Without it, $doc->getAttribute($twoWayKey) returns null for all matched documents, leaving $parentIds empty. As a result, the method returns null, making the filter behave as "no matches" even when valid matches exist.

The existing test passes because it manually pre-populates the relationship attribute on document creation ('students' => ['student1']), masking the issue in real-world usage without manual pre-population.

To fix this, ensure relationship population happens by either:

  • Treating system-only selects as "no explicit selects" for relationship population, or
  • Calling find() without any selects to allow the normal population flow to trigger

Also add tests for many-to-many filtering without manual relationship pre-population to prevent regression.

♻️ Duplicate comments (1)
src/Database/Database.php (1)

8707-8783: Remove unused $idAdded in processRelationshipQueries and keep $id auto-select relationship-only

The tail of processRelationshipQueries() still captures $idAdded from QueryContext::addSelect() but never uses it:

if (!empty($relationships)) {
    [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
}

PHPMD correctly flags $idAdded as unused, and per the earlier learning we only need the updated $queries while keeping $id auto-selected when relationships exist.

You can drop the unused variable by only capturing the first element:

Suggested change
-        /**
-         * In order to populateDocumentRelationships we need $id
-         */
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
+        /**
+         * In order to populateDocumentsRelationships we need $id
+         */
+        if (!empty($relationships)) {
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }

This matches the prior guidance for this method and clears the PHPMD warning.
Based on learnings, this is the intended pattern here.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 69d7013 and 67ed804.

📒 Files selected for processing (1)
  • src/Database/Database.php
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-11-25T10:46:37.666Z
Learnt from: fogelito
Repo: utopia-php/database PR: 763
File: src/Database/Database.php:8671-8751
Timestamp: 2025-11-25T10:46:37.666Z
Learning: In Utopia\Database\Database::processRelationshipQueries, ensure the system select for '$id' is added only when the collection has relationship attributes (i.e., !empty($relationships)), and avoid capturing the unused '$idAdded' from Query::addSelect to satisfy static analysis.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-10-03T02:04:17.803Z
Learnt from: abnegate
Repo: utopia-php/database PR: 721
File: tests/e2e/Adapter/Scopes/DocumentTests.php:6418-6439
Timestamp: 2025-10-03T02:04:17.803Z
Learning: In tests/e2e/Adapter/Scopes/DocumentTests::testSchemalessDocumentInvalidInteralAttributeValidation (PHP), when the adapter reports getSupportForAttributes() === false (schemaless), the test should not expect exceptions from createDocuments for “invalid” internal attributes; remove try/catch and ensure the test passes without exceptions, keeping at least one assertion.

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-10-20T05:29:29.487Z
Learnt from: abnegate
Repo: utopia-php/database PR: 731
File: src/Database/Database.php:6987-6988
Timestamp: 2025-10-20T05:29:29.487Z
Learning: In Utopia\Database\Database::convertRelationshipQueries, Many-to-Many filtering does not need the parent collection or a direct junction query: it calls find() on the related collection without skipping relationships, which populates relationship attributes (including the two-way key). Parent IDs are derived from that populated attribute. Therefore, calls should remain convertRelationshipQueries($relationships, $queries).

Applied to files:

  • src/Database/Database.php
📚 Learning: 2025-07-30T19:17:53.630Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 642
File: src/Database/Validator/PartialStructure.php:43-52
Timestamp: 2025-07-30T19:17:53.630Z
Learning: In PartialStructure validator, when filtering for required attributes validation using the $requiredAttributes parameter, $this->attributes should be used instead of the merged $attributes array because this validation is specifically for internal attributes like $createdAt and $updatedAt that are defined in the base Structure class, not collection-specific attributes.

Applied to files:

  • src/Database/Database.php
🧬 Code graph analysis (1)
src/Database/Database.php (2)
src/Database/Validator/Queries/V2.php (1)
  • V2 (23-714)
src/Database/QueryContext.php (3)
  • QueryContext (8-136)
  • add (68-76)
  • addSelect (102-135)
🪛 GitHub Actions: CodeQL
src/Database/Database.php

[error] 8203-8203: PHPStan error: No error to ignore is reported on line 8203.

🪛 PHPMD (2.15.0)
src/Database/Database.php

8779-8779: Avoid unused local variables such as '$idAdded'. (undefined)

(UnusedLocalVariable)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (SharedTables/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Unit Test

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.

3 participants