Skip to content

Conversation

@fogelito
Copy link
Contributor

@fogelito fogelito commented Nov 20, 2025

Summary by CodeRabbit

  • New Features

    • Join support added (inner/left/right) across queries.
    • QueryContext introduced to carry collection/alias/authorization context.
  • Changes

    • Selection API now uses single-field selects (compose multiple calls for multi-field projections).
    • Query flow updated to be context-driven; joins, cursor pagination and ordering behavior revised.
    • Validation engine upgraded to a v2, context-aware validator.
  • Tests

    • Expanded end-to-end join tests and updated tests for per-field selects and context-driven APIs.

✏️ 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: 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

# Conflicts:
#	src/Database/Adapter/SQL.php
#	src/Database/Validator/Queries.php
#	src/Database/Validator/Query/Filter.php
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: 4

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 (1)

8166-8213: sum() doesn't handle join queries like find() and count() do—missing context registration and auth validation.

While find() and count() extract join queries via Query::getJoinQueries($queries), register joined collections to QueryContext, and validate authorization for all collections, sum() only registers the primary collection and ignores joins entirely. The adapter's sum() signature doesn't even accept QueryContext (unlike count()), preventing proper join processing. Either implement full join support matching find()/count(), or reject join queries early with a clear QueryException to avoid silent failures.

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

1956-1988: Cursor pagination reads $cursor[...] keys without existence checks.
If the cursor payload is missing any ordered attribute (or has alias-qualified keys elsewhere), this will raise notices / break pagination unexpectedly.

Defensive checks
                 for ($j = 0; $j < $i; $j++) {
                     $originalPrev = $orderQueries[$j]->getAttribute();
                     $prevAttr = $this->filter($this->getInternalKeyForAttribute($originalPrev));
-                    $tmp = $cursor[$originalPrev];
+                    if (!array_key_exists($originalPrev, $cursor)) {
+                        throw new DatabaseException("Missing cursor value for order attribute: {$originalPrev}");
+                    }
+                    $tmp = $cursor[$originalPrev];
                     $andConditions[] = [
                         $prevAttr => $tmp
                     ];
                 }
 
-                $tmp = $cursor[$originalAttribute];
+                if (!array_key_exists($originalAttribute, $cursor)) {
+                    throw new DatabaseException("Missing cursor value for order attribute: {$originalAttribute}");
+                }
+                $tmp = $cursor[$originalAttribute];

2141-2227: count() likely incorrect for $tenant/other $... attributes + it silently returns 0 on MongoException.

  • Unlike find(), count() never runs replaceInternalIdsKeys(...), so filters like Query::equal('$tenant', ...) won’t match stored _tenant.
  • Swallowing exceptions (catch (MongoException) { return 0; }) will mask operational failures as “empty result”.
Proposed fix
     // Build filters from queries
     $filters = $this->buildFilters($filters);
 
     if ($this->sharedTables) {
         $filters['_tenant'] = $this->getTenantFilters($collection->getId());
     }
@@
+    // Keep count() filter translation consistent with find()
+    $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators);
@@
         $result = $this->client->aggregate($name, $pipeline, $options);
@@
         return 0;
     } catch (MongoException $e) {
-        return 0;
+        throw $this->processException($e);
     }
src/Database/Adapter/SQL.php (1)

3011-3073: Bug: cursor pagination uses the current order alias for previous-order equality clauses.
Inside the $j < $i loop you build equality predicates as {$orderAlias}.prevAttr = :cursor_j. If different order queries use different aliases (main vs joined tables), pagination becomes wrong.

Proposed fix
         foreach ($orderQueries as $i => $order) {
-            $orderAlias = $order->getAlias();
+            $orderAlias = $this->filter($order->getAlias());
@@
             for ($j = 0; $j < $i; $j++) {
                 $prevQuery = $orderQueries[$j];
+                $prevAlias = $this->filter($prevQuery->getAlias());
                 $prevOriginal = $prevQuery->getAttribute();
                 $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal));
@@
-                $conditions[] = "{$this->quote($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}";
+                $conditions[] = "{$this->quote($prevAlias)}.{$this->quote($prevAttr)} = {$bindName}";
             }
🤖 Fix all issues with AI agents
In `@src/Database/Adapter/Postgres.php`:
- Around line 1795-1799: The right alias in the Query::TYPE_RELATION_EQUAL case
is quoted without being sanitized; update the code to mirror the left side by
filtering the right alias before quoting (i.e., call
$this->filter($query->getRightAlias()) and then $this->quote(...) when building
$aliasRight) so getRightAlias() is sanitized prior to use in the returned
"{$alias}.{$attribute}={$aliasRight}.{$attributeRight}" expression.

In `@src/Database/Adapter/SQL.php`:
- Around line 2347-2385: getAttributeProjection is treating every element in the
passed array as a SELECT query; add a guard to skip non-select entries just like
getSQLConditions does. Inside getAttributeProjection (method name), before
processing each $select, check that $select->getType() === Query::TYPE_SELECT
(or equivalent TYPE_SELECT constant) and continue if not, so only SELECT queries
are used to build the projection; keep existing checks for '$collection' and
alias/as handling unchanged. Ensure you reference the same Query::TYPE_SELECT
symbol used elsewhere in the codebase to maintain consistency.

In `@src/Database/Database.php`:
- Around line 8320-8360: The decode() method (and the casting() block mentioned)
currently throws a generic \Exception('Invalid query: Unknown Alias context');
replace these throws with the existing QueryException type used by the query
flow so callers can catch query-specific errors consistently; update both the
throw in decode() and the similar throw in casting() (and any other occurrences
around lines ~8412-8459) to construct and throw QueryException with the same
message/context, and ensure the QueryException symbol is referenced/imported
correctly in this file so the new throws compile.

In `@src/Database/Validator/Queries/V2.php`:
- Around line 329-336: The switch branch handling Database::VAR_INTEGER uses an
undefined variable $attributeSchema; replace references to $attributeSchema with
the correct variable $attribute (used earlier) so signed and other flags are
read from $attribute; ensure you still reference $size for bit calculation and
instantiate Integer(false, $bits, $unsigned) as before (i.e., in the
Database::VAR_INTEGER case, read $signed = $attribute['signed'] ?? true and
compute $unsigned = !$signed && $bits < 64).
♻️ Duplicate comments (8)
src/Database/Query.php (1)

519-561: Validate new metadata fields to maintain consistent error handling

The parseQuery() method validates method, attribute, and values types, but the new metadata fields (alias, aliasRight, attributeRight, collection, as) are taken as-is from the decoded array without type checking. If a serialized query contains a non-string value for one of these keys (e.g., alias: 123 or aliasRight: []), the constructor's strict string parameter types will throw a TypeError instead of a QueryException.

Consider adding validation for consistency:

♻️ Suggested validation
// After line 528, add:
foreach (['alias', 'aliasRight', 'attributeRight', 'collection', 'as'] as $field) {
    if (isset($query[$field]) && !\is_string($query[$field])) {
        throw new QueryException("Invalid query {$field}. Must be a string, got " . \gettype($query[$field]));
    }
}
src/Database/Validator/Queries/V2.php (2)

210-225: Handle missing attributes safely in schemaless mode

When $supportForAttributes is false and the attribute is not found in the schema, $attribute is an empty array. The code continues past line 213 but then tries to access $attribute['type'] at line 219, which will cause an undefined index notice and incorrect behavior (the empty array comparison $attribute['type'] != Database::VAR_RELATIONSHIP will likely throw a warning or return unexpected results).

This was previously flagged in past reviews. Add an early return for schemaless mode when the attribute is unknown:

🔧 Proposed fix
         $attribute = $this->schema[$collection->getId()][$attributeId] ?? [];
-        if (empty($attribute) && $this->supportForAttributes) {
-            throw new \Exception('Invalid query: Attribute not found in schema: '.$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'] ?? [])) {

534-538: Join-scope alias check is too strict for non-relation queries

This block runs for every nested query under a join scope, but non-relation queries (filters, orders) typically have an empty rightAlias. The check !in_array($query->getRightAlias(), $this->joinsAliasOrder) will fail for empty strings since '' is not in $this->joinsAliasOrder, incorrectly rejecting valid filter queries inside joins.

Restrict the alias check to relation queries only:

🔧 Proposed fix
                 if ($scope === 'joins') {
-                    if (!in_array($query->getAlias(), $this->joinsAliasOrder) || !in_array($query->getRightAlias(), $this->joinsAliasOrder)){
+                    if ($query->getMethod() === Query::TYPE_RELATION_EQUAL &&
+                        (!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.');
                     }
                 }
src/Database/Database.php (2)

5591-5836: Post-write paths still skip Database::casting(...) before decode(...) in some branches.

This looks like the same “decode without casting” gap previously discussed for updateDocument(), updateDocuments(), and parts of upsertDocumentsWithIncrease(). If Database::casting() is required to normalize output types for some adapters, these returns will be inconsistent with getDocument() / find() / createDocument(s).

Also applies to: 5859-6064, 6624-6902


8787-8861: Remove unused $idAdded in processRelationshipQueries() (PHPMD) and keep $id injection relationship-only.

$idAdded is assigned but never used. Also, per earlier discussion, the $id system select should only be injected when relationships exist (!empty($relationships)), which you already have.

Proposed fix
 if (!empty($relationships)) {
-    [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+    [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
 }
src/Database/Adapter/SQL.php (2)

3079-3095: skipAuth() keying still looks suspicious (filtered $collection).
If QueryContext stores skip-auth keys using raw collection ids, filtering here can cause permissions to be applied unexpectedly.


3107-3114: RIGHT JOIN permission clause mixes quoted and unquoted alias usage ({$alias}._uid IS NULL).
This was already flagged earlier; it should use the same quoting helpers as getSQLPermissionsCondition().

Also applies to: 3269-3276

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

1939-1949: Guard against unsupported random ordering before calling getOrderDirection().
This adapter reports getSupportForOrderRandom() === false, and this exact pattern was already flagged previously for Mongo find().

🧹 Nitpick comments (9)
src/Database/Validator/Queries/V2.php (1)

46-46: Consider resetting $this->vectors at the start of isValid() to prevent cross-call state leakage

The $this->vectors counter is incremented when a vector query is validated (line 376) but never reset. If the same validator instance is reused for multiple separate isValid() calls (not nested, but sequential), the counter accumulates across calls. A second validation call containing a single valid vector query would incorrectly fail because $this->vectors > 0.

Per past discussion, you confirmed wanting this behavior for nested validations. However, for top-level calls, consider resetting at the start of isValid():

     public function isValid($value, string $scope = ''): bool
     {
+        // Reset per-validation state for top-level calls
+        if ($scope === '') {
+            $this->vectors = 0;
+        }
+
         try {

Also applies to: 502-504

src/Database/Database.php (3)

4295-4324: Prefer calling QueryContext::addSelect(...) directly (avoid $context::...) and keep the $permissions removal comment accurate.

[$selects, $permissionsAdded] = $context::addSelect(...) works but reads like an instance method; using QueryContext::addSelect(...) (or $context->addSelect(...) if you later make it non-static) is clearer. Also the comment “Or remove all queries added by system” doesn’t match the current behavior (only $permissions is removed).

Also applies to: 4411-4413


4986-5012: Handle SELECT aliases in applySelectFiltersToDocuments() (don’t accidentally strip selected fields).

If callers use Query::select('field', as: 'alias'), the document key will be the alias, but this method only whitelists getAttribute(), so it can remove a legitimately selected field.

Proposed fix
 foreach ($selectQueries as $selectQuery) {
-    $attributesToKeep[$selectQuery->getAttribute()] = true;
+    $attributesToKeep[$selectQuery->getAs() ?: $selectQuery->getAttribute()] = true;
 }

8725-8751: Cache key hashing via serialize($selects) is order-sensitive and object-serialization dependent.

This will create different cache keys for semantically equivalent selects with different ordering, and it relies on PHP object serialization stability for Query. Consider hashing a canonical representation (e.g., map to scalar arrays + sort) to reduce accidental cache fragmentation.

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

708-735: renameAttribute() should validate no-op/ambiguous renames (old == new) before issuing $rename.
Right now it will still run an update across the full collection even if $old === $new (or if both map to the same internal key), which is wasted work and can cause surprising behavior.

Proposed tweak
 public function renameAttribute(string $collection, string $old, string $new): bool
 {
     $collection = $this->getNamespace() . '_' . $this->filter($collection);
 
     $from    = $this->filter($this->getInternalKeyForAttribute($old));
     $to      = $this->filter($this->getInternalKeyForAttribute($new));
+    if ($from === $to) {
+        return true;
+    }
     $options = $this->getTransactionOptions();

1126-1156: Projection input type mismatch risk: getDocument() passes $queries into getAttributeProjection() (which expects selects).
If $queries can include non-SELECT query types, this can accidentally restrict the projection to filter attributes only.

Safer `getAttributeProjection()` (also covers `find()` callers)
 protected function getAttributeProjection(array $selects): array
 {
     $projection = [];
 
     if (empty($selects)) {
         return [];
     }
 
     foreach ($selects as $select) {
+        // Be defensive if callers pass mixed query arrays.
+        if (method_exists($select, 'getMethod') && $select->getMethod() !== Query::TYPE_SELECT) {
+            continue;
+        }
         if ($select->getAttribute() === '$collection') {
             continue;
         }

1876-1888: find() signature is compatible, but consider explicitly suppressing unused $joins/$vectors.
PHPMD is right: Mongo adapter can’t use them (no relationships/vectors), but the interface requires them.

Minimal suppression
 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 {
+    unset($joins, $vectors);
     $collection = $context->getMainCollection();

2574-2605: Projection builder should ignore non-select queries defensively.
Even if the “intended” contract is “selects only”, filtering by Query::TYPE_SELECT makes this safer (and avoids subtle bugs when a mixed query array is accidentally passed).

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

374-379: Consistency: quote _uid in getDocument() WHERE and don’t rely on raw ._uid.
Not a security issue by itself, but it’s inconsistent with the rest of the file’s quoting and can bite if quoting rules change.

Proposed tweak
-            WHERE {$this->quote($alias)}._uid = :_uid 
+            WHERE {$this->quote($alias)}.{$this->quote('_uid')} = :_uid 
📜 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 67ed804 and 1ff480f.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • src/Database/Adapter/Mongo.php
  • src/Database/Adapter/Postgres.php
  • src/Database/Adapter/SQL.php
  • src/Database/Database.php
  • src/Database/Query.php
  • src/Database/Validator/Queries/V2.php
  • src/Database/Validator/Structure.php
  • tests/e2e/Adapter/Scopes/AttributeTests.php
  • tests/e2e/Adapter/Scopes/CollectionTests.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
  • tests/unit/Validator/StructureTest.php
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/e2e/Adapter/Scopes/AttributeTests.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php
  • tests/e2e/Adapter/Scopes/CollectionTests.php
🧰 Additional context used
🧠 Learnings (8)
📚 Learning: 2025-07-01T11:31:37.438Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 613
File: src/Database/Adapter/Postgres.php:1254-1319
Timestamp: 2025-07-01T11:31:37.438Z
Learning: In PostgreSQL adapter methods like getUpsertStatement, complexity for database-specific SQL generation is acceptable when the main business logic is properly separated in the parent SQL adapter class, following the adapter pattern where each database adapter handles its own SQL syntax requirements.

Applied to files:

  • src/Database/Adapter/Postgres.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:

  • tests/unit/Validator/StructureTest.php
  • src/Database/Database.php
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php
  • src/Database/Adapter/Mongo.php
  • src/Database/Validator/Queries/V2.php
  • src/Database/Query.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
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php
  • src/Database/Adapter/SQL.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
  • tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.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/Structure.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/Adapter/Mongo.php
  • 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
🧬 Code graph analysis (6)
tests/unit/Validator/StructureTest.php (3)
src/Database/Validator/Structure.php (1)
  • getDescription (197-200)
src/Database/Validator/Operator.php (1)
  • getDescription (90-93)
src/Database/Validator/Sequence.php (1)
  • getDescription (14-17)
src/Database/Database.php (1)
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)
tests/e2e/Adapter/Scopes/SchemalessTests.php (1)
src/Database/Query.php (2)
  • Query (8-1587)
  • select (794-797)
tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php (2)
src/Database/Query.php (3)
  • Query (8-1587)
  • select (794-797)
  • limit (837-840)
src/Database/Database.php (1)
  • find (7822-7974)
tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php (1)
src/Database/Query.php (2)
  • Query (8-1587)
  • select (794-797)
src/Database/Adapter/SQL.php (3)
src/Database/QueryContext.php (2)
  • QueryContext (8-136)
  • getMainCollection (40-43)
src/Database/Adapter.php (4)
  • getAttributeProjection (1256-1256)
  • quote (491-491)
  • filter (1265-1274)
  • getTenantQuery (1356-1356)
src/Database/Query.php (4)
  • Query (8-1587)
  • select (794-797)
  • getAttribute (259-262)
  • join (1009-1012)
🪛 PHPMD (2.15.0)
src/Database/Database.php

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

(UnusedLocalVariable)

src/Database/Adapter/Mongo.php

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

(UnusedFormalParameter)


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

(UnusedFormalParameter)


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

(UnusedFormalParameter)

src/Database/Validator/Queries/V2.php

331-331: Avoid unused local variables such as '$attributeSchema'. (undefined)

(UnusedLocalVariable)

🔇 Additional comments (30)
tests/unit/Validator/StructureTest.php (1)

284-284: LGTM! Error message updates are consistent and informative.

The updated expected error messages correctly reflect the new validator output, providing explicit 32-bit signed integer range bounds (-2,147,483,648 to 2,147,483,647). This aligns with the PR's goal of providing more descriptive validation feedback.

Also applies to: 462-462, 477-477, 543-543, 665-665

src/Database/Validator/Structure.php (1)

351-356: The hardcoded false parameter is intentional; signedness validation is delegated to the Range validator.

The Integer(false, $bits, $unsigned) call is correct. The Integer validator handles type checking only, while the Range validator (line 359) correctly uses the attribute's $signed value to enforce the min/max range constraints. For unsigned values, the Range validator sets $min = 0; for signed values, it sets $min = -$max. This design is also used identically in Queries/V2.php and works correctly in all tests.

src/Database/Query.php (2)

1003-1050: LGTM on join factory methods

The new static factory methods for joins (join, innerJoin, leftJoin, rightJoin, relationEqual) are well-structured and correctly pass the new metadata fields (alias, collection, attributeRight, aliasRight) to the constructor.


1059-1070: No external usages of getByType were detected. The visibility reduction to protected appears to be an intentional internal refactoring. No action required.

Likely an incorrect or invalid review comment.

tests/e2e/Adapter/Scopes/SchemalessTests.php (2)

175-197: LGTM - Tests correctly updated for new Query::select() signature

The test updates correctly reflect the new Query::select(string $attribute, ...) signature, replacing the previous array-based syntax with individual string arguments. This aligns with the API change in Query.php.


862-886: LGTM - Internal attribute selection tests updated

The tests for selecting internal attributes ($id, $sequence, $collection, $createdAt, $updatedAt, $permissions) are correctly updated to use individual Query::select() calls per field, matching the new API design.

tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php (8)

119-123: LGTM!

The Query::select('name') usage correctly aligns with the new string-based API signature.


150-153: LGTM!

Splitting Query::select(['*', 'albums.name']) into separate Query::select('*') and Query::select('albums.name') calls correctly implements the new per-field selection API.


162-165: LGTM!

Consistent pattern applied to getDocument with separate select calls for * and albums.name.


588-591: LGTM!

Two-way relationship test correctly updated to use separate Query::select calls for * and accounts.name.


600-603: LGTM!

Consistent with the updated API pattern for getDocument with relationship attribute selection.


924-927: LGTM!

Single field selection Query::select('name') correctly updated.


931-934: LGTM!

Correctly using single Query::select('*') for wildcard selection.


938-943: LGTM!

Nested relationship selections (*, cities.*, cities.mayor.*) correctly split into separate Query::select calls, properly testing deep relationship field projections.

tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php (8)

171-173: LGTM!

The Query::select('name') usage correctly aligns with the new string-based API signature.


181-184: LGTM!

Correctly split into separate Query::select('*') and Query::select('library.name') calls for the one-to-one relationship test.


193-197: LGTM!

Three separate select calls for *, library.name, and $id correctly demonstrate the new per-field selection API. The explicit $id selection ensures the document ID is always available.


204-206: LGTM!

Single field selection for name with proper assertion that library is not included.


210-213: LGTM!

Wildcard selection correctly returns library as a string ID (not populated) when relationship fields aren't explicitly selected.


215-219: LGTM!

Using Query::select('library.*') correctly selects all fields from the related library document while excluding the parent's direct attributes like name.


663-666: LGTM!

Two-way one-to-one relationship test correctly updated with separate Query::select calls for * and city.name.


675-678: LGTM!

Consistent pattern applied to getDocument in the two-way relationship context.

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

1738-1748: LGTM on alias and attribute initialization.

The left-side alias correctly goes through filter() before quote() for proper sanitization. The setAttributeRight call appropriately normalizes the right attribute to its internal key.

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

19-19: Good: QueryContext import aligns Mongo adapter with context-driven API.


1791-1793: Nice: deleteDocuments() uses Query::equal('$sequence', ...) and reuses buildFilters().


2009-2058: Good: batch iteration + killCursors cleanup reduces cursor resource leak risk.

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

20-21: Good: QueryContext import is consistent with the adapter API refactor.


1803-1808: Nice: JOIN query methods are mapped to SQL JOIN keywords via getSQLOperator().


2305-2338: getTenantQuery(..., forceIsNull) needs careful validation with RIGHT JOIN semantics.
The OR ... IS NULL clause can widen results significantly; please confirm this is only applied when it’s required for RIGHT JOIN correctness (and not for unrelated queries).


3228-3323: count() signature and flow look aligned with the JOIN-aware find(), but recheck RIGHT JOIN + tenant widening.
Specifically validate combined behavior of:

  • ($permissionsCondition OR alias._uid IS NULL) (once quoted), and
  • getTenantQuery(... forceIsNull: $rightJoins)
    to ensure it doesn’t admit cross-tenant rows when joined side is NULL.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +1795 to +1799
case Query::TYPE_RELATION_EQUAL:
$attributeRight = $this->quote($this->filter($query->getAttributeRight()));
$aliasRight = $this->quote($query->getRightAlias());

return "{$alias}.{$attribute}={$aliasRight}.{$attributeRight}";
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 | 🟠 Major

Missing filter() call on right alias.

The right alias is directly quoted without sanitization, unlike the left alias which is filtered at line 1746 before quoting. This inconsistency could lead to SQL injection or malformed queries if getRightAlias() returns unsanitized input.

🔒 Proposed fix
 case Query::TYPE_RELATION_EQUAL:
     $attributeRight = $this->quote($this->filter($query->getAttributeRight()));
-    $aliasRight = $this->quote($query->getRightAlias());
+    $aliasRight = $this->quote($this->filter($query->getRightAlias()));

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}";
🤖 Prompt for AI Agents
In `@src/Database/Adapter/Postgres.php` around lines 1795 - 1799, The right alias
in the Query::TYPE_RELATION_EQUAL case is quoted without being sanitized; update
the code to mirror the left side by filtering the right alias before quoting
(i.e., call $this->filter($query->getRightAlias()) and then $this->quote(...)
when building $aliasRight) so getRightAlias() is sanitized prior to use in the
returned "{$alias}.{$attribute}={$aliasRight}.{$attributeRight}" expression.

Comment on lines +2347 to 2385
protected function getAttributeProjection(array $selects): string
{
if (empty($selections) || \in_array('*', $selections)) {
return "{$this->quote($prefix)}.*";

if (empty($selects)) {
return $this->quote(Query::DEFAULT_ALIAS).'.*';
}

// Handle specific selections with spatial conversion where needed
$internalKeys = [
'$id',
'$sequence',
'$permissions',
'$createdAt',
'$updatedAt',
];
$string = '';
foreach ($selects as $select) {
if ($select->getAttribute() === '$collection') {
continue;
}

$selections = \array_diff($selections, [...$internalKeys, '$collection']);
$alias = $select->getAlias();
$alias = $this->filter($alias);

$attribute = $this->getInternalKeyForAttribute($select->getAttribute());

foreach ($internalKeys as $internalKey) {
$selections[] = $this->getInternalKeyForAttribute($internalKey);
}

$projections = [];
foreach ($selections as $selection) {
$filteredSelection = $this->filter($selection);
$quotedSelection = $this->quote($filteredSelection);
$projections[] = "{$this->quote($prefix)}.{$quotedSelection}";
if ($attribute !== '*') {
$attribute = $this->filter($attribute);
$attribute = $this->quote($attribute);
}

$as = $select->getAs();

if (!empty($as)) {
$as = ' as '.$this->quote($this->filter($as));
}

if (!empty($string)) {
$string .= ', ';
}

$string .= "{$this->quote($alias)}.{$attribute}{$as}";
}

return \implode(',', $projections);
return $string;
}
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the structure and find the Query class
fd -t f "\.php$" | head -20

Repository: utopia-php/database

Length of output: 579


🏁 Script executed:

# Find the Query class definition
rg "class Query" --type php -A 5

Repository: utopia-php/database

Length of output: 1341


🏁 Script executed:

# Check for TYPE_SELECT constant
rg "TYPE_SELECT" --type php

Repository: utopia-php/database

Length of output: 2462


🏁 Script executed:

# Look at the select parameter type hint - need to find what type $selects contains
rg "getAttributeProjection" --type php -B 5 -A 15

Repository: utopia-php/database

Length of output: 9828


🏁 Script executed:

# Find the getMethod() method definition
rg "function getMethod\|public function getMethod" --type php -A 3

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look at where getAttributeProjection is called and see the full context
rg "getAttributeProjection" --type php -B 20 | head -100

Repository: utopia-php/database

Length of output: 5559


🏁 Script executed:

# Check how selects/queries parameters are built before being passed to getAttributeProjection
rg "getAttributeProjection\(\$" --type php -B 30 | head -150

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look at the find method in SQL.php that calls getAttributeProjection
sed -n '2200,2400p' src/Database/Adapter/SQL.php

Repository: utopia-php/database

Length of output: 4950


🏁 Script executed:

# Check Query class to understand all query types and getMethod
rg "public const TYPE_" src/Database/Query.php | head -20

Repository: utopia-php/database

Length of output: 1014


🏁 Script executed:

# Look at the Query class structure more carefully
sed -n '1,150p' src/Database/Query.php

Repository: utopia-php/database

Length of output: 4872


🏁 Script executed:

# Check Mongo adapter's getAttributeProjection implementation
sed -n '1620,1680p' src/Database/Adapter/Mongo.php

Repository: utopia-php/database

Length of output: 2159


🏁 Script executed:

# Check if there are any tests showing mixed arrays being passed to these methods
rg "getAttributeProjection" --type php -A 5 tests/

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for where find() and getDocument() methods get their queries parameter to see if mixed arrays are realistic
sed -n '2100,2250p' src/Database/Adapter/SQL.php

Repository: utopia-php/database

Length of output: 4348


🏁 Script executed:

# Check the find method more carefully to see how it processes queries before calling getAttributeProjection
rg "protected function find" src/Database/Adapter/SQL.php -A 100 | head -150

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Look for where selects are extracted from queries
rg "\$selects\s*=" src/Database/Adapter/SQL.php -B 3 -A 3

Repository: utopia-php/database

Length of output: 45


🏁 Script executed:

# Let's look at the full find method to understand the flow
rg "public function find" src/Database/Adapter/SQL.php -A 150 | head -200

Repository: utopia-php/database

Length of output: 5991


🏁 Script executed:

# Look for where the queries parameter is used before getAttributeProjection
sed -n '1900,2100p' src/Database/Adapter/SQL.php

Repository: utopia-php/database

Length of output: 8251


🏁 Script executed:

# Check how queries are processed - look for array_filter or similar operations on $queries
rg "array_filter|array_map" src/Database/Adapter/SQL.php -B 2 -A 2

Repository: utopia-php/database

Length of output: 3017


🏁 Script executed:

# Let's search how the queries parameter flows through find()
rg "function find\(" src/Database/Adapter/SQL.php -A 200 | head -250

Repository: utopia-php/database

Length of output: 7527


🏁 Script executed:

# Find all places that call getAttributeProjection
rg "getAttributeProjection\(" src/ --type php -B 5

Repository: utopia-php/database

Length of output: 2853


🏁 Script executed:

# Check the getDocument method signature and how it's called
rg "public function getDocument" src/Database/Adapter/SQL.php -A 30

Repository: utopia-php/database

Length of output: 1941


Add TYPE_SELECT guard to getAttributeProjection().

The method receives a generic array $queries parameter in getDocument() without pre-filtering by query type. If a caller passes a mixed array containing both SELECT and non-SELECT queries (filters, pagination, ordering), non-SELECT queries will be incorrectly processed as projection attributes, resulting in malformed SQL.

The pattern of defensive filtering is already established in the codebase—getSQLConditions() explicitly filters SELECT queries out of mixed arrays. Apply the same defensive check here.

Suggested guard
 foreach ($selects as $select) {
+    if ($select->getMethod() !== Query::TYPE_SELECT) {
+        continue;
+    }
     if ($select->getAttribute() === '$collection') {
         continue;
     }
🤖 Prompt for AI Agents
In `@src/Database/Adapter/SQL.php` around lines 2347 - 2385,
getAttributeProjection is treating every element in the passed array as a SELECT
query; add a guard to skip non-select entries just like getSQLConditions does.
Inside getAttributeProjection (method name), before processing each $select,
check that $select->getType() === Query::TYPE_SELECT (or equivalent TYPE_SELECT
constant) and continue if not, so only SELECT queries are used to build the
projection; keep existing checks for '$collection' and alias/as handling
unchanged. Ensure you reference the same Query::TYPE_SELECT symbol used
elsewhere in the codebase to maintain consistency.

Comment on lines 8320 to 8360
public function decode(QueryContext $context, Document $document, array $selects = []): Document
{
$attributes = \array_filter(
$collection->getAttribute('attributes', []),
fn ($attribute) => $attribute['type'] !== self::VAR_RELATIONSHIP
);

$relationships = \array_filter(
$collection->getAttribute('attributes', []),
fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP
);

$filteredValue = [];
$internals = [];
$schema = [];

foreach ($relationships as $relationship) {
$key = $relationship['$id'] ?? '';
foreach (Database::INTERNAL_ATTRIBUTES as $attribute) {
$internals[$attribute['$id']] = $attribute;
}

if (
\array_key_exists($key, (array)$document)
|| \array_key_exists($this->adapter->filter($key), (array)$document)
) {
$value = $document->getAttribute($key);
$value ??= $document->getAttribute($this->adapter->filter($key));
$document->removeAttribute($this->adapter->filter($key));
$document->setAttribute($key, $value);
foreach ($context->getCollections() as $collection) {
foreach ($collection->getAttribute('attributes', []) as $attribute) {
$key = $attribute->getAttribute('key', $attribute->getAttribute('$id'));
$key = $this->adapter->filter($key);
$schema[$collection->getId()][$key] = $attribute->getArrayCopy();
}
}

foreach ($this->getInternalAttributes() as $attribute) {
$attributes[] = $attribute;
}
foreach ($document as $key => $value) {
$alias = Query::DEFAULT_ALIAS;
$attributeKey = '';

foreach ($attributes as $attribute) {
$key = $attribute['$id'] ?? '';
$type = $attribute['type'] ?? '';
$array = $attribute['array'] ?? false;
$filters = $attribute['filters'] ?? [];
$value = $document->getAttribute($key);
foreach ($selects as $select) {
if ($select->getAs() === $key) {
$attributeKey = $key;
$key = $select->getAttribute();
$alias = $select->getAlias();
break;
}

if ($key === '$permissions') {
continue;
if ($select->getAttribute() == $key ||
$this->adapter->filter($select->getAttribute()) == $key) {
$alias = $select->getAlias();
break;
}
}

if (\is_null($value)) {
$value = $document->getAttribute($this->adapter->filter($key));
$collection = $context->getCollectionByAlias($alias);
if ($collection->isEmpty()) {
throw new \Exception('Invalid query: Unknown Alias context');
}

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 | 🟠 Major

Throw QueryException (not generic \Exception) for unknown alias context in decode()/casting().

Right now decode()/casting() throw \Exception('Invalid query: Unknown Alias context'), which is harder for callers to handle consistently (and bypasses the existing QueryException flow used elsewhere).

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

Also applies to: 8412-8459

🤖 Prompt for AI Agents
In `@src/Database/Database.php` around lines 8320 - 8360, The decode() method (and
the casting() block mentioned) currently throws a generic \Exception('Invalid
query: Unknown Alias context'); replace these throws with the existing
QueryException type used by the query flow so callers can catch query-specific
errors consistently; update both the throw in decode() and the similar throw in
casting() (and any other occurrences around lines ~8412-8459) to construct and
throw QueryException with the same message/context, and ensure the
QueryException symbol is referenced/imported correctly in this file so the new
throws compile.

# Conflicts:
#	composer.lock
#	src/Database/Adapter/Mongo.php
#	src/Database/Database.php
#	src/Database/Query.php
#	tests/unit/Validator/QueriesTest.php
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: 6

Caution

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

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

1224-1306: groupByType() assigns the wrong fallback variables for offset and cursor.
$offset and $cursor currently fall back to $limit, which can silently corrupt pagination.

Proposed fix
 case Query::TYPE_OFFSET:
@@
-    $offset = $values[0] ?? $limit;
+    $offset = $values[0] ?? $offset;
     break;
@@
 case Query::TYPE_CURSOR_AFTER:
 case Query::TYPE_CURSOR_BEFORE:
@@
-    $cursor = $values[0] ?? $limit;
+    $cursor = $values[0] ?? $cursor;
     $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? Database::CURSOR_AFTER : Database::CURSOR_BEFORE;
     break;
src/Database/Adapter/Mongo.php (2)

1128-1159: Projection bug: getDocument() passes mixed $queries into getAttributeProjection()

getAttributeProjection() assumes a list of SELECT queries, but getDocument() passes $queries (potentially mixed: filters/orders/etc.). If any non-select query is present, projection can unintentionally exclude fields and return incomplete documents.

Fix either by (a) passing only selects into getAttributeProjection(), or (b) making getAttributeProjection() ignore non-TYPE_SELECT entries.

Suggested guard (option b)
 protected function getAttributeProjection(array $selects): array
 {
     $projection = [];

     if (empty($selects)) {
         return [];
     }

     foreach ($selects as $select) {
+        if ($select->getMethod() !== Query::TYPE_SELECT) {
+            continue;
+        }
         if ($select->getAttribute() === '$collection') {
             continue;
         }
         // ...
     }

     return $projection;
 }

2042-2074: Inconsistent result normalization between first batch and getMore

First batch does new Document($this->convertStdClassToArray($record)), but getMore path does new Document($record) (no deep conversion). This will produce inconsistent shapes/types across pages.

Also: unlike getDocument(), find() is not applying castingAfter($collection, $doc), so type casting may silently regress.

Unify both paths (convert + cast in both).

Proposed fix
-                $doc = new Document($this->convertStdClassToArray($record));
+                $doc = new Document($this->convertStdClassToArray($record));
                 if ($removeSequence) {
                     $doc->removeAttribute('$sequence');
                 }
+                $doc = $this->castingAfter($collection, $doc);
                 $found[] = $doc;

@@
-                    $doc = new Document($record);
+                    $doc = new Document($this->convertStdClassToArray($record));
                     if ($removeSequence) {
                         $doc->removeAttribute('$sequence');
                     }
+                    $doc = $this->castingAfter($collection, $doc);
                     $found[] = $doc;
🤖 Fix all issues with AI agents
In `@src/Database/Adapter/SQL.php`:
- Around line 374-379: In getDocument() (where the SQL SELECT is built) the
WHERE clause mixes a quoted alias with an unquoted column
(`{$this->quote($alias)}._uid`); change the column to be quoted as well so
identifier quoting is consistent — replace `{$this->quote($alias)}._uid` with
`{$this->quote($alias)}.{$this->quote('_uid')}` (keep the surrounding use of
getAttributeProjection, getSQLTable, quote($alias) and getTenantQuery as-is).

In `@src/Database/Database.php`:
- Around line 7858-7867: The comment in find() contradicts the code: either
update the comment to state that when no unique order on $id or $sequence is
found the code appends Query::orderAsc() to $orders, or change the logic so the
default order is only appended when not in a join context (e.g., add/check an
$isJoin or similar flag before pushing Query::orderAsc()); locate the
$uniqueOrderBy variable, the foreach over $orders, and the Query::orderAsc()
append and implement one of these fixes to make behavior and comment consistent.
- Around line 8383-8388: The early-return in the casting method is inverted:
change the condition in Database::casting(QueryContext $context, Document
$document, array $selects = []) so the method returns immediately when the
adapter reports it DOES support casting (adapter->getSupportForCasting() ===
true), and only performs manual casting when getSupportForCasting() is false
(e.g., Mongo). Locate the casting method and flip the boolean check on
$this->adapter->getSupportForCasting(), preserving the existing return
$document; behavior and ensuring manual casting logic runs for adapters that do
not support casting.
♻️ Duplicate comments (11)
src/Database/Database.php (4)

5794-5798: Post-write paths still skip casting() before decode() (type normalization inconsistency).
This appears to repeat the earlier review concern: updateDocument() and updateDocuments() decode without casting, and upsertDocumentsWithIncrease() decodes only in one branch.

Also applies to: 6024-6030, 6847-6851


5950-5957: Bulk update/delete: either auto-add required system selects or document the requirement.
These runtime checks (“Missing required attribute … in select query”) are easy to trip if callers use any explicit selects. Consider auto-injecting $permissions and $sequence similarly to other system-select logic.

Also applies to: 7632-7640


8327-8330: Throw QueryException (not generic \\Exception) for unknown alias context in decode()/casting().
Keeps error handling consistent with the rest of the query pipeline.

Also applies to: 8426-8429


8869-8875: Fix unused $idAdded and avoid auto-adding $id when selects are empty (same projection-mode risk).
Also matches the prior guidance: only inject $id for relationship-bearing collections, and don’t capture the unused flag. Based on learnings, keep $id injection relationship-only and remove $idAdded.

Proposed fix
-        if (!empty($relationships)) {
-            [$queries, $idAdded] = QueryContext::addSelect($queries, Query::select('$id', system: true));
-        }
+        if (!empty($relationships) && !empty($queries)) { // only when in projection mode
+            [$queries] = QueryContext::addSelect($queries, Query::select('$id', system: true));
+        }
src/Database/Query.php (2)

79-127: Query::TYPES is now out of sync with isMethod() (join/relation types missing).
If any validators/consumers rely on Query::TYPES for “allowed methods”, they’ll reject join/relation queries even though parseQuery() accepts them via isMethod().

Proposed fix
 public const TYPES = [
@@
         self::TYPE_ELEM_MATCH,
-        self::TYPE_REGEX
+        self::TYPE_REGEX,
+        self::TYPE_RELATION_EQUAL,
+        self::TYPE_INNER_JOIN,
+        self::TYPE_LEFT_JOIN,
+        self::TYPE_RIGHT_JOIN,
     ];

Also applies to: 407-461


525-568: parseQuery() should validate join metadata + nested elements to avoid leaking TypeError.
Right now attributeRight/alias/aliasRight/as/collection can be non-strings and nested values[] can be non-arrays, which will throw a TypeError (or argument type error) instead of a QueryException.

Proposed fix
 public static function parseQuery(array $query): self
 {
     $method = $query['method'] ?? '';
     $attribute = $query['attribute'] ?? '';
     $attributeRight = $query['attributeRight'] ?? '';
     $values = $query['values'] ?? [];
     $alias = $query['alias'] ?? '';
     $aliasRight = $query['aliasRight'] ?? '';
     $as = $query['as'] ?? '';
     $collection = $query['collection'] ?? '';

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

-    if (\in_array($method, self::LOGICAL_TYPES) || \in_array($method, self::JOINS_TYPES)) {
+    if (\in_array($method, self::LOGICAL_TYPES, true) || \in_array($method, self::JOINS_TYPES, true)) {
         foreach ($values as $index => $value) {
+            if (!\is_array($value)) {
+                throw new QueryException("Invalid query values[{$index}]. Must be an array, got " . \gettype($value));
+            }
             $values[$index] = self::parseQuery($value);
         }
     }
src/Database/Adapter/Postgres.php (1)

1745-1755: Filter getRightAlias() before quoting in TYPE_RELATION_EQUAL (injection/malformed identifier risk).
Left alias is filtered, but right alias isn’t.

Proposed fix
 case Query::TYPE_RELATION_EQUAL:
     $attributeRight = $this->quote($this->filter($query->getAttributeRight()));
-    $aliasRight = $this->quote($query->getRightAlias());
+    $aliasRight = $this->quote($this->filter($query->getRightAlias()));

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

Also applies to: 1802-1807

tests/e2e/Adapter/Scopes/DocumentTests.php (1)

4279-4283: Decode input likely should be the encoded $result, not the original $document (still).
This matches the earlier review note; if the intent is to test decode independently, an inline comment would help.

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

-        $result = $database->decode($context, $document);
+        $result = $database->decode($context, $result);
src/Database/Adapter/Mongo.php (1)

1910-2024: Guard against ORDER_RANDOM before calling getOrderDirection()
This code still calls $order->getOrderDirection() for every order. If an orderRandom() query reaches Mongo, this can throw and take down the request path; either explicitly reject it here (clear “not supported”) or ensure it’s filtered earlier.

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

2357-2395: getAttributeProjection() must ignore non-SELECT queries (especially because getDocument() passes $queries)

Without a TYPE_SELECT guard, any non-select query in $queries can be rendered into the SELECT list, producing invalid SQL or wrong projections.

Proposed fix
 foreach ($selects as $select) {
+    if ($select->getMethod() !== Query::TYPE_SELECT) {
+        continue;
+    }
     if ($select->getAttribute() === '$collection') {
         continue;
     }
     // ...
 }

3091-3124: RIGHT JOIN permission clause uses unquoted {$alias}._uid IS NULL + verify skipAuth() keying

  • The OR {$alias}._uid IS NULL branch should use the same quoting helper as getSQLPermissionsCondition().
  • Also, skipAuth() is called with filtered collection IDs; please confirm QueryContext::addSkipAuth() uses the same (filtered vs raw) keys, otherwise skip-auth may silently fail.

Also applies to: 3253-3286

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

8739-8765: Replace md5(serialize($selects)) with a normalized string using stable Query fields.

Object serialization includes internal implementation details (like $onArray, $isObjectAttribute) that aren't part of the public API. If the Query class evolves, the serialization hash changes even when the logical selection hasn't, causing unnecessary cache misses. Instead, build the hash from stable getter methods—getAlias(), getAttribute(), getAs(), getMethod(), isSystem()—to create a deterministic key tied only to the relevant selection parameters.

tests/e2e/Adapter/Scopes/DocumentTests.php (2)

3197-3214: Query::select('director') addition is correct for the new per-field select API.
Optional: consider also asserting the selected field is present (e.g., director) since you already assert name is absent.


3792-3930: Find-select tests are updated correctly for per-field selects + internal-field visibility.
This gives solid coverage across user fields and system fields ($id, $sequence, $createdAt, $updatedAt, $permissions).
Optional: the repeated “internal keys not present” assertions could be extracted into a tiny helper to reduce duplication/noise.

src/Database/Adapter.php (1)

829-841: Avoid the “mega-signature” by introducing a single query DTO (optional)

find() now has many parallel arrays ($selects/$filters/$joins/$vectors/$orderQueries), which is hard to evolve and easy to call incorrectly. Consider a QueryPlan/FindParams value object (or moving more of these into QueryContext) so future additions don’t keep widening the interface.

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

2704-2737: Projection builder should reuse getInternalKeyForAttribute() and ignore non-selects

Right now this re-implements the internal attribute mapping via an inline match, and (as above) it will happily treat non-select queries as projection entries.

  • Prefer $this->getInternalKeyForAttribute($attribute) for consistency.
  • Add a TYPE_SELECT guard to make it safe when callers pass mixed query arrays.
📜 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 1ff480f and b4d14af.

📒 Files selected for processing (12)
  • src/Database/Adapter.php
  • src/Database/Adapter/MariaDB.php
  • src/Database/Adapter/Mongo.php
  • src/Database/Adapter/Pool.php
  • src/Database/Adapter/Postgres.php
  • src/Database/Adapter/SQL.php
  • src/Database/Database.php
  • src/Database/Query.php
  • src/Database/Validator/Queries/V2.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • tests/e2e/Adapter/Scopes/ObjectAttributeTests.php
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/Database/Adapter/MariaDB.php
  • src/Database/Validator/Queries/V2.php
🧰 Additional context used
🧠 Learnings (7)
📚 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/ObjectAttributeTests.php
  • src/Database/Adapter.php
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • src/Database/Adapter/SQL.php
  • src/Database/Query.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:

  • tests/e2e/Adapter/Scopes/ObjectAttributeTests.php
  • src/Database/Adapter/Postgres.php
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • src/Database/Adapter/SQL.php
  • src/Database/Query.php
  • src/Database/Database.php
📚 Learning: 2025-07-01T11:31:37.438Z
Learnt from: ArnabChatterjee20k
Repo: utopia-php/database PR: 613
File: src/Database/Adapter/Postgres.php:1254-1319
Timestamp: 2025-07-01T11:31:37.438Z
Learning: In PostgreSQL adapter methods like getUpsertStatement, complexity for database-specific SQL generation is acceptable when the main business logic is properly separated in the parent SQL adapter class, following the adapter pattern where each database adapter handles its own SQL syntax requirements.

Applied to files:

  • src/Database/Adapter/Postgres.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-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-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/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 (6)
tests/e2e/Adapter/Scopes/ObjectAttributeTests.php (1)
src/Database/Query.php (2)
  • Query (8-1614)
  • select (800-803)
src/Database/Adapter/Postgres.php (4)
src/Database/Query.php (5)
  • getAttributeRight (296-299)
  • getAttribute (264-267)
  • getAlias (286-289)
  • Query (8-1614)
  • getRightAlias (291-294)
src/Database/Adapter/SQL.php (1)
  • getInternalKeyForAttribute (2397-2409)
src/Database/Adapter.php (1)
  • quote (491-491)
src/Database/Adapter/MariaDB.php (1)
  • quote (1921-1924)
src/Database/Adapter.php (2)
src/Database/Adapter/Mongo.php (2)
  • find (1910-2099)
  • getAttributeProjection (2704-2737)
src/Database/Adapter/SQL.php (2)
  • find (2994-3224)
  • getAttributeProjection (2357-2395)
tests/e2e/Adapter/Scopes/SchemalessTests.php (1)
src/Database/Query.php (3)
  • Query (8-1614)
  • select (800-803)
  • getAttribute (264-267)
src/Database/Adapter/Mongo.php (4)
src/Database/QueryContext.php (3)
  • QueryContext (8-136)
  • getMainCollection (40-43)
  • skipAuth (83-94)
src/Database/Adapter/Postgres.php (1)
  • renameAttribute (533-548)
src/Database/Adapter/SQL.php (4)
  • renameAttribute (310-327)
  • getInternalKeyForAttribute (2397-2409)
  • find (2994-3224)
  • count (3238-3333)
src/Database/Document.php (3)
  • removeAttribute (287-293)
  • getId (63-66)
  • Document (12-470)
src/Database/Database.php (4)
src/Database/Validator/Queries/V2.php (1)
  • V2 (23-719)
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/Query.php (10)
  • Query (8-1614)
  • getSelectQueries (1082-1087)
  • select (800-803)
  • join (1015-1018)
  • getCollection (306-309)
  • groupByType (1224-1306)
  • limit (843-846)
  • offset (854-857)
  • isNested (1313-1320)
  • isJoin (1325-1328)
src/Database/Adapter.php (3)
  • count (865-870)
  • filter (1272-1281)
  • find (829-841)
🪛 PHPMD (2.15.0)
src/Database/Adapter/Mongo.php

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

(UnusedFormalParameter)


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

(UnusedFormalParameter)


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

(UnusedFormalParameter)

src/Database/Database.php

8873-8873: 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). (1)
  • GitHub Check: Setup & Build Docker Image
🔇 Additional comments (16)
src/Database/Database.php (3)

34-34: Validator alias swap looks fine, but ensure “V2” is the intended class for this file’s query-validation contract.
No issues in this hunk by itself.


4959-4983: applySelectFiltersToDocuments() looks good for post-fetch projection.
Nice to see internal attributes preserved and wildcard handled early.


4266-4274: This review comment is incorrect. The QueryContext::addSelect() method does not flip empty selects into projection mode.

When $selects is empty, the foreach loop at line 108 does not execute (no SELECT queries to iterate), so $found remains false. The condition if ($found && $merge) at line 125 evaluates to false, causing the method to return the original empty array unchanged with false for the second value. No $permissions select is added when the array is empty.

The current code at lines 4292-4295 is safe and the proposed guard (if (!empty($selects))) is unnecessary.

Likely an incorrect or invalid review comment.

tests/e2e/Adapter/Scopes/ObjectAttributeTests.php (1)

219-239: Per-field Query::select() updates look correct and keep intent readable.

Also applies to: 532-577, 836-866

tests/e2e/Adapter/Scopes/SchemalessTests.php (1)

176-201: Per-field Query::select() updates are consistent with the new selection API.

Also applies to: 919-944

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

271-285: LGTM!

The updated find method signature properly adopts the context-first approach with QueryContext, and the delegation via func_get_args() correctly forwards all parameters to the underlying adapter.


292-299: LGTM!

The updated count method signature correctly adopts the context-first approach with QueryContext and separates filters from joins for cleaner query building.


526-529: LGTM!

The simplified signature for getAttributeProjection removing the $prefix parameter aligns with the QueryContext-based alias resolution approach.

tests/e2e/Adapter/Scopes/DocumentTests.php (6)

24-24: Import looks good; keep it consistent with other test files.
QueryContext is now referenced later in testEncodeDecode, so this inclusion is justified.


1441-1477: Select-projection assertions match the new “explicit selects only” behavior.
Good coverage for “internal fields excluded unless explicitly selected” (and $collection still present).


1481-1514: Nice guard test: reject non-select queries in getDocument “select” context.
This is useful to prevent silent misuse of the API; the assertions on message + exception type are sufficient.


1999-2004: Good addition: notSearch behavior exercised alongside search.
The expected count (4 out of 6) aligns with the seeded dataset in this test.


5020-5034: Bulk delete select lists updated cleanly (spread + array_map(Query::select) pattern).
This reads well and keeps the test intent clear while adapting to Query::select(string $field).

Also applies to: 5231-5246, 5254-5274


5509-5512: Query::select('*') is explicitly supported as a special-case "all fields" selector.

The codebase confirms '*' has defined semantics: when used with Query::select('*'), it returns all fields including internal fields (mapped in adapters as _uid, _id, _tenant, _createdAt, _updatedAt, _permissions). This is intentionally implemented in both Mongo and SQL adapters and is widely used throughout tests. No risk of confusion or footgun here.

src/Database/Adapter.php (1)

1260-1264: getAttributeProjection(array $selects) signature change looks consistent

Good direction aligning projection on “select queries” rather than generic selections.

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

720-737: renameAttribute() correctly maps internal attributes before $rename

Using getInternalKeyForAttribute() for $old/$new avoids mismatches for $id/$sequence/....

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines +865 to +870
abstract public function count(
QueryContext $context,
?int $max,
array $filters,
array $joins,
): int;
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check PHP version requirements in composer.json
if [ -f composer.json ]; then
  cat composer.json | jq '.require.php // .config.platform.php // empty'
fi

Repository: utopia-php/database

Length of output: 70


🏁 Script executed:

# Find the exact location and content of the method
cat -n src/Database/Adapter.php | sed -n '860,875p'

Repository: utopia-php/database

Length of output: 514


🏁 Script executed:

# Search for all implementations of count() method to check override compatibility
rg -B 2 -A 5 'function count\(' --type php

Repository: utopia-php/database

Length of output: 2421


Fix override compatibility violation in Mongo.php adapter

The abstract method requires array $filters and array $joins as non-optional parameters, but the Mongo.php adapter adds default values (= []) to both. This violates the Liskov Substitution Principle—callers of the abstract contract cannot safely use the concrete implementation with fewer arguments.

Ensure all concrete adapters match the abstract signature exactly: remove the = [] defaults from Mongo.php's count() method, or add them to all implementations consistently if the abstract method contract changes to make these parameters optional.

The trailing comma after $joins is valid in PHP 8.1+ (project requirement) and can remain.

Comment on lines +2174 to 2175
public function count(QueryContext $context, ?int $max, array $filters = [], array $joins = []): int
{
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 | 🔴 Critical

count() signature likely incompatible with abstract method (defaults on required params)

Abstract Adapter::count() requires $filters/$joins (no defaults). Here they’re optional (= []), which can trigger a fatal “must be compatible” error.

Either remove defaults here, or make the abstract signature optional and update all implementations to match.

Proposed fix (minimal)
-public function count(QueryContext $context, ?int $max, array $filters = [], array $joins = []): int
+public function count(QueryContext $context, ?int $max, array $filters, array $joins): int
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public function count(QueryContext $context, ?int $max, array $filters = [], array $joins = []): int
{
public function count(QueryContext $context, ?int $max, array $filters, array $joins): int
{
🧰 Tools
🪛 PHPMD (2.15.0)

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

(UnusedFormalParameter)

Comment on lines 374 to 379
$sql = "
SELECT {$this->getAttributeProjection($selections, $alias)}
SELECT {$this->getAttributeProjection($queries)}
FROM {$this->getSQLTable($name)} AS {$this->quote($alias)}
WHERE {$this->quote($alias)}.{$this->quote('_uid')} = :_uid
WHERE {$this->quote($alias)}._uid = :_uid
{$this->getTenantQuery($collection, $alias)}
";
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

Quote _uid consistently in getDocument() WHERE clause

This mixes quoted alias with unquoted column ({$this->quote($alias)}._uid). It’s low risk, but it breaks consistency with the rest of the adapter and could bite if identifier quoting rules change.

Proposed fix
-            WHERE {$this->quote($alias)}._uid = :_uid 
+            WHERE {$this->quote($alias)}.{$this->quote('_uid')} = :_uid 
🤖 Prompt for AI Agents
In `@src/Database/Adapter/SQL.php` around lines 374 - 379, In getDocument() (where
the SQL SELECT is built) the WHERE clause mixes a quoted alias with an unquoted
column (`{$this->quote($alias)}._uid`); change the column to be quoted as well
so identifier quoting is consistent — replace `{$this->quote($alias)}._uid` with
`{$this->quote($alias)}.{$this->quote('_uid')}` (keep the surrounding use of
getAttributeProjection, getSQLTable, quote($alias) and getTenantQuery as-is).

Comment on lines 3059 to 3069
// Add equality conditions for previous attributes
for ($j = 0; $j < $i; $j++) {
$prevOriginal = $orderAttributes[$j];
$prevQuery = $orderQueries[$j];
$prevOriginal = $prevQuery->getAttribute();
$prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal));

$bindName = ":cursor_{$j}";
$binds[$bindName] = $cursor[$prevOriginal];

$conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}";
$conditions[] = "{$this->quote($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}";
}
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 | 🟠 Major

Cursor pagination bug when ordering spans multiple aliases

Inside the cursorWhere builder, previous-order equality conditions use the current $orderAlias:

$conditions[] = "{$this->quote($orderAlias)}.... = :cursor_{$j}";

…but prevQuery can have a different alias (especially with joins). This yields incorrect pagination predicates.

Use $prevQuery->getAlias() for the $j conditions.

Proposed fix
 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($orderAlias)}.{$this->quote($prevAttr)} = {$bindName}";
+    $conditions[] = "{$this->quote($prevAlias)}.{$this->quote($prevAttr)} = {$bindName}";
 }

Comment on lines 7858 to 7867
$uniqueOrderBy = false;
foreach ($orderAttributes as $order) {
if ($order === '$id' || $order === '$sequence') {
foreach ($orders as $order) {
if ($order->getAttribute() === '$id' || $order->getAttribute() === '$sequence') {
$uniqueOrderBy = true;
}
}

if ($uniqueOrderBy === false) {
$orderAttributes[] = '$sequence';
$orders[] = Query::orderAsc(); // In joins we should not add a default order, we should validate when using a cursor we should have a unique order
}
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

find(): comment contradicts behavior (default order is still added).
Either remove/update the comment or adjust logic; currently it says “we should not add a default order” but then appends Query::orderAsc().

🤖 Prompt for AI Agents
In `@src/Database/Database.php` around lines 7858 - 7867, The comment in find()
contradicts the code: either update the comment to state that when no unique
order on $id or $sequence is found the code appends Query::orderAsc() to
$orders, or change the logic so the default order is only appended when not in a
join context (e.g., add/check an $isJoin or similar flag before pushing
Query::orderAsc()); locate the $uniqueOrderBy variable, the foreach over
$orders, and the Query::orderAsc() append and implement one of these fixes to
make behavior and comment consistent.

Comment on lines +8383 to 8388
public function casting(QueryContext $context, Document $document, array $selects = []): Document
{
if (!$this->adapter->getSupportForCasting()) {
return $document;
}

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the getSupportForCasting() method definition and its documentation
rg -n "function getSupportForCasting\(\)" src/Database/ -A5 -B2

Repository: utopia-php/database

Length of output: 1788


🏁 Script executed:

#!/bin/bash
# Check all adapter implementations to see what they return
rg -n "getSupportForCasting" src/Database/Adapter/ -B3 -A2

Repository: utopia-php/database

Length of output: 1105


🏁 Script executed:

#!/bin/bash
# Look at the full casting() method implementation to understand its purpose
rg -n "public function casting\(" src/Database/Database.php -A50

Repository: utopia-php/database

Length of output: 2109


🏁 Script executed:

#!/bin/bash
# Search for any documentation or comments about what getSupportForCasting means
rg -n "SupportForCasting" src/Database/ -B3 -A3

Repository: utopia-php/database

Length of output: 2022


The casting() early-return condition is inverted.

The method currently skips casting work when getSupportForCasting() is false (like Mongo), but it should skip when it's true (like SQL). SQL adapters return true and handle type casting natively in their drivers, so the Database class should not duplicate that work. Mongo returns false precisely because it doesn't support automatic type casting at the adapter level, making it the case where Database needs to perform manual casting.

Fix
-        if (!$this->adapter->getSupportForCasting()) {
+        if ($this->adapter->getSupportForCasting()) {
             return $document;
         }
🤖 Prompt for AI Agents
In `@src/Database/Database.php` around lines 8383 - 8388, The early-return in the
casting method is inverted: change the condition in
Database::casting(QueryContext $context, Document $document, array $selects =
[]) so the method returns immediately when the adapter reports it DOES support
casting (adapter->getSupportForCasting() === true), and only performs manual
casting when getSupportForCasting() is false (e.g., Mongo). Locate the casting
method and flip the boolean check on $this->adapter->getSupportForCasting(),
preserving the existing return $document; behavior and ensuring manual casting
logic runs for adapters that do not support casting.

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/Query.php (1)

468-494: Fix instance method calls to static isSpatialQuery method.

The code has two instances where isSpatialQuery() is called as an instance method but it's now a static method requiring a $method parameter. These will fail at runtime:

  1. Line 131 (src/Database/Validator/Query/Filter.php): Change $query->isSpatialQuery() to Query::isSpatialQuery($method) (the $method parameter is already available).

  2. Line 488 (src/Database/Validator/Query/Filter.php): Change $value->isSpatialQuery() to Query::isSpatialQuery($value->getMethod()) (where $value is a Query instance).

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

529-550: New metadata fields lack type validation.

While $method and $attribute are validated (lines 536-546), the new fields ($attributeRight, $alias, $aliasRight, $as, $collection) are not type-checked. If malformed JSON provides a non-string value (e.g., "alias": []), the constructor will throw a TypeError instead of a QueryException, making error handling inconsistent.

♻️ Suggested validation
$stringFields = ['attributeRight' => $attributeRight, 'alias' => $alias, 'aliasRight' => $aliasRight, 'as' => $as, 'collection' => $collection];
foreach ($stringFields as $name => $value) {
    if (!\is_string($value)) {
        throw new QueryException("Invalid query {$name}. Must be a string, got " . \gettype($value));
    }
}
🧹 Nitpick comments (5)
src/Database/Query.php (5)

191-192: Missing getter for $system property.

The $system property is set via the constructor and select() factory (line 802), but there's no getSystem(): bool getter. Other metadata properties like $alias, $collection, $as all have corresponding getters. For consistency, consider adding:

public function isSystem(): bool
{
    return $this->system;
}

347-375: Use QueryException instead of generic \Exception.

getCursorDirection() (line 357) and getOrderDirection() (line 374) throw generic \Exception. For consistency with the rest of this class (e.g., parse(), parseQuery()), use QueryException:

♻️ Suggested fix
-        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');

1015-1029: join() is redundant with innerJoin().

join() and innerJoin() are identical implementations. Consider either:

  1. Removing join() to avoid confusion, or
  2. Documenting that join() is an alias for innerJoin()

If kept as an alias, add a docblock note:

/**
 * Alias for innerJoin()
 * `@see` innerJoin()
 */

1177-1189: Method naming inconsistency: getCursorQueries returns single Query|null.

getCursorQueries returns ?Query (a single query or null), but the plural "Queries" suffix suggests it returns an array like the other get*Queries methods. Consider renaming to getCursorQuery for clarity:

♻️ Suggested rename
-    public static function getCursorQueries(array $queries): ?Query
+    public static function getCursorQuery(array $queries): ?Query

1325-1333: Minor API inconsistency between isJoin() and isFilter().

isJoin() is an instance method (like isNested()), while isFilter() is static. This creates an inconsistent API:

  • $query->isJoin()
  • Query::isFilter($method)

Consider making isFilter an instance method or adding a static isJoinType(string $method) variant for symmetry. Not blocking, but worth considering for API consistency.

📜 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 6339269 and bc98c1a.

📒 Files selected for processing (2)
  • src/Database/Query.php
  • tests/e2e/Adapter/Base.php
🧰 Additional context used
🧠 Learnings (2)
📚 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/Base.php
  • src/Database/Query.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/Query.php
⏰ 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/MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MySQL)
  • GitHub Check: Adapter Tests (SharedTables/Postgres)
  • GitHub Check: Adapter Tests (MySQL)
  • GitHub Check: Adapter Tests (Pool)
  • GitHub Check: Adapter Tests (SharedTables/SQLite)
  • GitHub Check: Adapter Tests (Schemaless/MongoDB)
  • GitHub Check: Adapter Tests (SQLite)
  • GitHub Check: Adapter Tests (MariaDB)
  • GitHub Check: Adapter Tests (Mirror)
  • GitHub Check: Adapter Tests (MongoDB)
  • GitHub Check: Adapter Tests (SharedTables/MariaDB)
  • GitHub Check: Adapter Tests (Postgres)
  • GitHub Check: Unit Test
🔇 Additional comments (4)
tests/e2e/Adapter/Base.php (1)

12-12: LGTM!

The new JoinsTests trait is correctly imported and integrated into the base e2e test class. The import is properly placed in alphabetical order, and the trait usage follows the existing pattern in the class. This cleanly enables the new join-related tests to execute across all adapter implementations.

Also applies to: 27-27

src/Database/Query.php (3)

147-182: Logical types TYPE_AND and TYPE_OR in FILTER_TYPES may cause unexpected behavior.

TYPE_AND and TYPE_OR (lines 178-179) are logical container types, not filter predicates. Including them in FILTER_TYPES means getFilterQueries() will return nested logical queries alongside regular filters, which may not be the expected behavior for callers expecting only leaf filter predicates.

If this is intentional (to support recursive filter traversal), consider documenting this behavior. Otherwise, these should be in LOGICAL_TYPES only.


451-459: LGTM!

Join and relation-equal types are now correctly included in isMethod(), addressing the serialization/parsing concern from previous reviews.


600-632: LGTM!

Serialization now properly includes the new metadata fields and handles join child queries correctly. The conditional inclusion of aliases (only when non-default) keeps payloads minimal.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

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