Skip to content

Add contains all#816

Merged
abnegate merged 5 commits intomainfrom
feat-contains-all
Feb 17, 2026
Merged

Add contains all#816
abnegate merged 5 commits intomainfrom
feat-contains-all

Conversation

@abnegate
Copy link
Member

@abnegate abnegate commented Feb 17, 2026

Summary by CodeRabbit

  • New Features

    • Added containsAny and containsAll query operators across adapters and query builder for array/object containment.
  • Bug Fixes

    • Improved relationship resolution to support containsAll in nested/multi-hop relationship queries and avoid unmatchable patterns.
  • Deprecated

    • contains() marked deprecated for array attributes; use containsAny or containsAll.
  • Tests

    • Added end-to-end tests covering containsAny/containsAll behavior, relationship cases, and related error handling.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

Adds TYPE_CONTAINS_ANY and TYPE_CONTAINS_ALL query methods across Query, validators, and adapters; refactors nested relationship traversal into resolveRelationshipGroupToIds() and processNestedRelationshipPath(); and adds e2e tests for containsAny/containsAll and many-to-many relationship containsAll scenarios.

Changes

Cohort / File(s) Summary
Query Type Definitions
src/Database/Query.php
Added TYPE_CONTAINS_ANY and TYPE_CONTAINS_ALL, new helpers containsAny()/containsAll(), updated TYPES and isMethod(), and deprecation guidance for array usage of contains().
Database Adapters
src/Database/Adapter/MariaDB.php, src/Database/Adapter/Mongo.php, src/Database/Adapter/Postgres.php, src/Database/Adapter/SQL.php
Integrated new contains variants into adapters: Mongo maps to $in/$all, Postgres/MariaDB add JSON/JSONB containment paths (@> / JSON_CONTAINS) for arrays, SQL maps contains variants to LIKE for non-JSON paths; unified containment handling across adapters.
Validation
src/Database/Validator/Queries.php, src/Database/Validator/Query/Filter.php
Validator now recognizes and validates TYPE_CONTAINS_ANY and TYPE_CONTAINS_ALL alongside existing contains types and ensures correct value shape for object/array containment.
Relationship Resolution
src/Database/Database.php
Refactored multi-hop relationship query resolution; added private helpers resolveRelationshipGroupToIds() and processNestedRelationshipPath(); detects duplicate equal queries on the same relationship attr and returns/integrates resolved IDs into main query flow.
Tests
tests/e2e/Adapter/Scopes/RelationshipTests.php, tests/e2e/Adapter/Scopes/AttributeTests.php, tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php
Added e2e tests for containsAny/containsAll on array attributes, many-to-many containsAll dot-path scenarios, and a duplicate-equal-queries error test; note: one new test was accidentally duplicated in ManyToManyTests.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Query
    participant Database
    participant Adapter
    Client->>Query: build containsAny/containsAll query
    Query->>Database: execute(query)
    Database->>Database: group dot-path relationship queries
    Database->>Database: processNestedRelationshipPath(...) / resolveRelationshipGroupToIds(...)
    Database->>Adapter: fetch related IDs (forward/reverse, nested)
    Adapter-->>Database: return ids
    Database->>Database: integrate resolved IDs into main query criteria
    Database->>Adapter: run final query with resolved criteria
    Adapter-->>Client: return results
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • fogelito
  • ArnabChatterjee20k

Poem

"I hop through indexes, sniffing ALL and ANY bright,
I tuck arrays in burrows, keep queries light.
With whiskers twitching, relationships I trace—
IDs found, paths mended, each match in place.
🐇✨"

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add contains all' is partially related to the changeset. While it refers to a real addition (TYPE_CONTAINS_ALL constant and containsAll method), the PR also introduces TYPE_CONTAINS_ANY and broader relationship resolution improvements that are equally significant but not mentioned in the title.
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-contains-all

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

❤️ Share

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

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

2762-2771: ⚠️ Potential issue | 🔴 Critical

TYPE_CONTAINS_ALL on object attributes incorrectly uses $in instead of $all.

In handleObjectFilters, TYPE_CONTAINS_ALL falls through to the same case as TYPE_CONTAINS and TYPE_CONTAINS_ANY, which sets $operator = '$in'. This gives "any" semantics, not "all" semantics. For TYPE_CONTAINS_ALL, the operator should be $all.

Proposed fix
             case Query::TYPE_CONTAINS:
             case Query::TYPE_CONTAINS_ANY:
             case Query::TYPE_CONTAINS_ALL:
             case Query::TYPE_NOT_CONTAINS: {
                 $arrayValue = \is_array($queryValue) ? $queryValue : [$queryValue];
-                $operator = $isNot ? '$nin' : '$in';
+                if ($query->getMethod() === Query::TYPE_CONTAINS_ALL) {
+                    $operator = '$all';
+                } else {
+                    $operator = $isNot ? '$nin' : '$in';
+                }
                 $conditions[] = [ $flattenedObjectKey => [ $operator => $arrayValue] ];
                 break;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Mongo.php` around lines 2762 - 2771, In
handleObjectFilters, Query::TYPE_CONTAINS_ALL is currently grouped with
TYPE_CONTAINS and TYPE_CONTAINS_ANY so it uses the '$in' operator; update the
switch so that when $query->getMethod() === Query::TYPE_CONTAINS_ALL you set
$operator = $isNot ? '$nin' : '$all' (or otherwise use '$all' for positive
semantics) and keep TYPE_CONTAINS/TYPE_CONTAINS_ANY using '$in'; modify the case
labels so TYPE_CONTAINS_ALL has its own branch (or conditional) to choose '$all'
instead of '$in' when constructing $conditions for $flattenedObjectKey.
src/Database/Adapter/Postgres.php (2)

1714-1738: ⚠️ Potential issue | 🔴 Critical

TYPE_CONTAINS_ALL on object attributes uses OR separator, giving "any" semantics instead of "all".

The $separator is ' OR ' for non-NOT queries (line 1737), so each @> condition is joined with OR. For CONTAINS_ALL, the conditions should be joined with AND so that all values must be present.

Proposed fix
             case Query::TYPE_CONTAINS:
             case Query::TYPE_CONTAINS_ANY:
             case Query::TYPE_CONTAINS_ALL:
             case Query::TYPE_NOT_CONTAINS: {
                 $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS;
                 $conditions = [];
                 foreach ($query->getValues() as $key => $value) {
                     // ...binding logic...
                     $fragment = "{$alias}.{$attribute} @> :{$placeholder}_{$key}::jsonb";
                     $conditions[] = $isNot ? "NOT (" . $fragment . ")" : $fragment;
                 }
-                $separator = $isNot ? ' AND ' : ' OR ';
+                $separator = ($isNot || $query->getMethod() === Query::TYPE_CONTAINS_ALL) ? ' AND ' : ' OR ';
                 return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')';
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Postgres.php` around lines 1714 - 1738, The current
branch that builds JSON containment conditions always uses ' OR ' for non-NOT
queries, which makes Query::TYPE_CONTAINS_ALL behave like "any" instead of
"all"; change the separator logic in the Postgres adapter case handling (the
block that inspects Query::TYPE_CONTAINS, TYPE_CONTAINS_ANY, TYPE_CONTAINS_ALL,
TYPE_NOT_CONTAINS) so that if $query->getMethod() === Query::TYPE_CONTAINS_ALL
you use ' AND ' to join $conditions, if $isNot is true keep ' AND ' (negated
fragments must all hold), otherwise keep ' OR ' for
TYPE_CONTAINS/TYPE_CONTAINS_ANY; update the code that sets $separator (currently
$separator = $isNot ? ' AND ' : ' OR ') to reflect these three cases.

1817-1848: ⚠️ Potential issue | 🟠 Major

TYPE_CONTAINS_ALL falls through to the default path for non-array attributes, but is missing from the value-matching arm and uses wrong separator.

When onArray() is false, TYPE_CONTAINS_ALL falls through to the default block. Two issues:

  1. The value-matching arm lists Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY but not Query::TYPE_CONTAINS_ALL, so values won't get % LIKE wildcards.
  2. The separator will be ' OR ' instead of ' AND ', giving "any" semantics instead of "all" semantics.

The parent SQL class correctly handles TYPE_CONTAINS_ALL in getSQLOperator(), returning the LIKE operator, so the operator mapping is correct. However, both the value transformation and separator logic need fixing when non-array containsAll is used with multiple values.

Proposed fix
-                       Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',
+                       Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_CONTAINS_ALL => ($query->onArray()) ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%',

And for the separator:

-               $separator = $isNotQuery ? ' AND ' : ' OR ';
+               $isAllQuery = $query->getMethod() === Query::TYPE_CONTAINS_ALL;
+               $separator = ($isNotQuery || $isAllQuery) ? ' AND ' : ' OR ';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Postgres.php` around lines 1817 - 1848,
TYPE_CONTAINS_ALL currently falls through to the default path for non-array
attributes but isn’t handled in the value transformation or separator logic;
update the default block to include Query::TYPE_CONTAINS_ALL in the match arm
that applies LIKE/wildcard transformations (alongside TYPE_CONTAINS and
TYPE_CONTAINS_ANY) so values get '%' wrappers via escapeWildcards, and set the
join separator to ' AND ' (instead of the default ' OR ') when
$query->getMethod() === Query::TYPE_CONTAINS_ALL and !$query->onArray(); keep
using $this->getSQLOperator($query->getMethod()) for the operator and preserve
existing array handling for onArray().
src/Database/Adapter/MariaDB.php (1)

1621-1636: ⚠️ Potential issue | 🟠 Major

TYPE_CONTAINS_ALL on non-array attributes produces incorrect OR logic instead of AND.

When $query->onArray() is false, the TYPE_CONTAINS_ALL case falls through to the default block (line 1655). There, it is not explicitly handled in the match statement and gets processed as a generic value. The default block then loops through values and joins them with ' OR ' (line 1665), producing (attr LIKE ...) OR (attr LIKE ...) semantics, which is equivalent to containsAny, not containsAll.

For example, containsAll(['foo', 'bar']) on a non-array string should mean "contains both 'foo' AND 'bar'", but instead produces "(contains 'foo') OR (contains 'bar')".

Array attributes correctly use JSON_CONTAINS for subset semantics (line 1622), but non-array strings need explicit handling with AND logic or should be explicitly disallowed at validation.

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

In `@src/Database/Adapter/MariaDB.php` around lines 1621 - 1636, The
TYPE_CONTAINS_ALL branch falls through for non-array attributes and ends up
using the default logic which joins per-value LIKE clauses with OR; change the
handling so that when Query::TYPE_CONTAINS_ALL and $query->onArray() is false
you explicitly build per-value binds from $query->getValues() (using
:{$placeholder}_n keys into $binds) and return a combined expression joining
each "({$alias}.{$attribute} LIKE :{$placeholder}_n)" with AND, or alternatively
throw/validate to disallow containsAll on non-array attributes; update the
match/switch to include this explicit case (referencing TYPE_CONTAINS_ALL,
$query->onArray(), $query->getValues(), $binds, $placeholder, $alias and
$attribute) instead of falling through to the default OR logic.
🧹 Nitpick comments (5)
src/Database/Validator/Query/Filter.php (1)

377-390: Same grouping inconsistency as in Queries.phpTYPE_CONTAINS_ALL is separated from TYPE_CONTAINS_ANY.

Consider grouping all contains variants together in the switch for consistency.

Suggested grouping
             case Query::TYPE_EQUAL:
             case Query::TYPE_CONTAINS:
             case Query::TYPE_CONTAINS_ANY:
-            case Query::TYPE_NOT_CONTAINS:
             case Query::TYPE_CONTAINS_ALL:
+            case Query::TYPE_NOT_CONTAINS:
             case Query::TYPE_EXISTS:
             case Query::TYPE_NOT_EXISTS:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Validator/Query/Filter.php` around lines 377 - 390, The switch
in Filter.php groups Query::TYPE_CONTAINS_ALL separately from the other contains
variants; update the case grouping so TYPE_CONTAINS_ALL is adjacent to
TYPE_CONTAINS and TYPE_CONTAINS_ANY (i.e., include TYPE_CONTAINS_ALL in the same
case block that checks $this->isEmpty($value->getValues()) and then calls
$this->isValidAttributeAndValues($attribute, $value->getValues(), $method)),
keeping the existing $this->message assignment and return behavior and
preserving use of $method and $value->getValues().
src/Database/Validator/Queries.php (1)

106-112: Group TYPE_CONTAINS_ALL next to the other contains variants for consistency.

TYPE_CONTAINS_ANY is placed next to TYPE_CONTAINS (Line 107), but TYPE_CONTAINS_ALL is placed after TYPE_OR (Line 111). Both are contains variants and should be co-located for readability and maintainability.

Suggested grouping
                 Query::TYPE_CONTAINS,
                 Query::TYPE_CONTAINS_ANY,
+                Query::TYPE_CONTAINS_ALL,
                 Query::TYPE_NOT_CONTAINS,
                 Query::TYPE_AND,
                 Query::TYPE_OR,
-                Query::TYPE_CONTAINS_ALL,
                 Query::TYPE_ELEM_MATCH,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Validator/Queries.php` around lines 106 - 112, The enum-like
ordering mixes contains variants: move Query::TYPE_CONTAINS_ALL so it sits with
the other contains entries (Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY,
Query::TYPE_NOT_CONTAINS, Query::TYPE_CONTAINS_ALL, Query::TYPE_ELEM_MATCH)
instead of after Query::TYPE_OR; update the list in the code block where these
constants are declared so all "contains" types are co-located for consistency
(refer to Query::TYPE_CONTAINS_ALL, Query::TYPE_CONTAINS_ANY, and Query::TYPE_OR
to find the items to reorder).
src/Database/Query.php (3)

842-850: New containsAll() factory method looks good.

Consistent with the containsAny() pattern. Missing a @return in the docblock says Query, correct.

One minor note: the docblock for containsAll lacks a description (unlike containsAny which has one). Consider adding a brief description for consistency.

Suggested docblock improvement
     /**
+     * Helper method to create Query with containsAll method.
+     * For array attributes, matches documents where the attribute contains ALL of the given values.
+     *
      * `@param` string $attribute
      * `@param` array<mixed> $values
      * `@return` Query
      */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Query.php` around lines 842 - 850, Add a brief description to
the containsAll() docblock consistent with containsAny() (e.g., "Create a Query
that matches items containing all given values.") and ensure the `@param` and
`@return` annotations match the existing style used by containsAny() (keep `@return`
Query if that is the project's convention) so the docblock is consistent and
descriptive for the containsAll() factory method.

69-69: TYPE_CONTAINS_ALL is declared in the "Logical methods" section instead of with the other filter/contains constants.

This constant is a filter type (like TYPE_CONTAINS at line 17 and TYPE_CONTAINS_ANY at line 18), not a logical combinator like TYPE_AND/TYPE_OR. Placing it here is misleading. Move it next to line 18 for consistency.

Proposed relocation
     public const TYPE_CONTAINS = 'contains';
     public const TYPE_CONTAINS_ANY = 'containsAny';
+    public const TYPE_CONTAINS_ALL = 'containsAll';
     public const TYPE_NOT_CONTAINS = 'notContains';

And remove the declaration from line 69.

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

In `@src/Database/Query.php` at line 69, The constant TYPE_CONTAINS_ALL is
misplaced in the "Logical methods" section; move its declaration next to the
other filter/contains constants (near TYPE_CONTAINS and TYPE_CONTAINS_ANY) and
remove the duplicate declaration from the logical section (where
TYPE_AND/TYPE_OR live) so all filter/contains constants are grouped together and
the logical combinators remain separate.

540-550: Add #[Deprecated] attribute to the contains() method for runtime deprecation notices.

The project targets PHP 8.4+, which supports the native #[Deprecated] attribute. Using it alongside the @deprecated docblock will generate runtime deprecation notices in addition to static analysis coverage.

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

In `@src/Database/Query.php` around lines 540 - 550, Add the native #[Deprecated]
attribute to the contains method so it emits runtime deprecation notices: update
the Query::contains method declaration to include a #[Deprecated(...)] attribute
(optionally include a message like "Use containsAny() for arrays or contains()
for strings" and a since version) while keeping the existing `@deprecated`
docblock and returning the same signature and behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Database.php`:
- Around line 9111-9133: The containsAll branch always adds Query::equal('$id',
$ids) which ignores the attribute returned by resolveRelationshipGroupToIds
(e.g., relationshipKey) and breaks parent‑side relationship filtering; change
the logic that appends to $additionalQueries to use the attribute from the
resolver result (falling back to '$id' if not provided) — i.e., extract the
returned attribute (from the $result returned by resolveRelationshipGroupToIds,
e.g., 'relationshipKey' or similar) and call Query::equal($attribute, $ids)
instead of Query::equal('$id', $ids) so containsAll filters the correct field
for one‑to‑one/many‑to‑one relationships.

---

Outside diff comments:
In `@src/Database/Adapter/MariaDB.php`:
- Around line 1621-1636: The TYPE_CONTAINS_ALL branch falls through for
non-array attributes and ends up using the default logic which joins per-value
LIKE clauses with OR; change the handling so that when Query::TYPE_CONTAINS_ALL
and $query->onArray() is false you explicitly build per-value binds from
$query->getValues() (using :{$placeholder}_n keys into $binds) and return a
combined expression joining each "({$alias}.{$attribute} LIKE
:{$placeholder}_n)" with AND, or alternatively throw/validate to disallow
containsAll on non-array attributes; update the match/switch to include this
explicit case (referencing TYPE_CONTAINS_ALL, $query->onArray(),
$query->getValues(), $binds, $placeholder, $alias and $attribute) instead of
falling through to the default OR logic.

In `@src/Database/Adapter/Mongo.php`:
- Around line 2762-2771: In handleObjectFilters, Query::TYPE_CONTAINS_ALL is
currently grouped with TYPE_CONTAINS and TYPE_CONTAINS_ANY so it uses the '$in'
operator; update the switch so that when $query->getMethod() ===
Query::TYPE_CONTAINS_ALL you set $operator = $isNot ? '$nin' : '$all' (or
otherwise use '$all' for positive semantics) and keep
TYPE_CONTAINS/TYPE_CONTAINS_ANY using '$in'; modify the case labels so
TYPE_CONTAINS_ALL has its own branch (or conditional) to choose '$all' instead
of '$in' when constructing $conditions for $flattenedObjectKey.

In `@src/Database/Adapter/Postgres.php`:
- Around line 1714-1738: The current branch that builds JSON containment
conditions always uses ' OR ' for non-NOT queries, which makes
Query::TYPE_CONTAINS_ALL behave like "any" instead of "all"; change the
separator logic in the Postgres adapter case handling (the block that inspects
Query::TYPE_CONTAINS, TYPE_CONTAINS_ANY, TYPE_CONTAINS_ALL, TYPE_NOT_CONTAINS)
so that if $query->getMethod() === Query::TYPE_CONTAINS_ALL you use ' AND ' to
join $conditions, if $isNot is true keep ' AND ' (negated fragments must all
hold), otherwise keep ' OR ' for TYPE_CONTAINS/TYPE_CONTAINS_ANY; update the
code that sets $separator (currently $separator = $isNot ? ' AND ' : ' OR ') to
reflect these three cases.
- Around line 1817-1848: TYPE_CONTAINS_ALL currently falls through to the
default path for non-array attributes but isn’t handled in the value
transformation or separator logic; update the default block to include
Query::TYPE_CONTAINS_ALL in the match arm that applies LIKE/wildcard
transformations (alongside TYPE_CONTAINS and TYPE_CONTAINS_ANY) so values get
'%' wrappers via escapeWildcards, and set the join separator to ' AND ' (instead
of the default ' OR ') when $query->getMethod() === Query::TYPE_CONTAINS_ALL and
!$query->onArray(); keep using $this->getSQLOperator($query->getMethod()) for
the operator and preserve existing array handling for onArray().

---

Nitpick comments:
In `@src/Database/Query.php`:
- Around line 842-850: Add a brief description to the containsAll() docblock
consistent with containsAny() (e.g., "Create a Query that matches items
containing all given values.") and ensure the `@param` and `@return` annotations
match the existing style used by containsAny() (keep `@return` Query if that is
the project's convention) so the docblock is consistent and descriptive for the
containsAll() factory method.
- Line 69: The constant TYPE_CONTAINS_ALL is misplaced in the "Logical methods"
section; move its declaration next to the other filter/contains constants (near
TYPE_CONTAINS and TYPE_CONTAINS_ANY) and remove the duplicate declaration from
the logical section (where TYPE_AND/TYPE_OR live) so all filter/contains
constants are grouped together and the logical combinators remain separate.
- Around line 540-550: Add the native #[Deprecated] attribute to the contains
method so it emits runtime deprecation notices: update the Query::contains
method declaration to include a #[Deprecated(...)] attribute (optionally include
a message like "Use containsAny() for arrays or contains() for strings" and a
since version) while keeping the existing `@deprecated` docblock and returning the
same signature and behavior.

In `@src/Database/Validator/Queries.php`:
- Around line 106-112: The enum-like ordering mixes contains variants: move
Query::TYPE_CONTAINS_ALL so it sits with the other contains entries
(Query::TYPE_CONTAINS, Query::TYPE_CONTAINS_ANY, Query::TYPE_NOT_CONTAINS,
Query::TYPE_CONTAINS_ALL, Query::TYPE_ELEM_MATCH) instead of after
Query::TYPE_OR; update the list in the code block where these constants are
declared so all "contains" types are co-located for consistency (refer to
Query::TYPE_CONTAINS_ALL, Query::TYPE_CONTAINS_ANY, and Query::TYPE_OR to find
the items to reorder).

In `@src/Database/Validator/Query/Filter.php`:
- Around line 377-390: The switch in Filter.php groups Query::TYPE_CONTAINS_ALL
separately from the other contains variants; update the case grouping so
TYPE_CONTAINS_ALL is adjacent to TYPE_CONTAINS and TYPE_CONTAINS_ANY (i.e.,
include TYPE_CONTAINS_ALL in the same case block that checks
$this->isEmpty($value->getValues()) and then calls
$this->isValidAttributeAndValues($attribute, $value->getValues(), $method)),
keeping the existing $this->message assignment and return behavior and
preserving use of $method and $value->getValues().

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Database.php`:
- Around line 9246-9265: The code currently replaces $relatedQueries with
Query::equal('$id', $matchingIds) when nested paths are found, which drops
direct filters; instead, keep any non-nested filters and append the $id
equality: after obtaining $matchingIds from processNestedRelationshipPath, if
not null/empty build a new array that includes the original $relatedQuery
entries whose getAttribute() does NOT contain '.' and then add
Query::equal('$id', $matchingIds) (preserving the early return when $matchingIds
is null/empty); update the $relatedQueries variable to this merged list so both
direct filters and the id filter are applied.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

1687-1723: Consider adding containsAll tests for string attributes and error paths.

The array-attribute tests are correct. Two gaps worth considering:

  1. No containsAll test on non-array string attributes (tv_show, pref) — the containsAny section covers these but containsAll doesn't. If containsAll on a plain string should behave like a multi-substring check (all substrings must be present), it would be good to have an explicit test.
  2. No error-handling test for containsAny/containsAll on a non-array, non-string attribute like age — there's already such a test for contains at Line 1614, and parity would prevent silent regressions if validation differs across query types.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/Adapter/Scopes/AttributeTests.php` around lines 1687 - 1723, Add
tests that cover containsAll behavior on string attributes and error paths for
non-array/string attributes: add cases calling Query::containsAll('tv_show',
[...]) and Query::containsAll('pref', [...]) verifying expected matches when all
substrings are present and non-matches when any substring is missing; and add
negative tests invoking Query::containsAll('age', [...]) and
Query::containsAny('age', [...]) that assert an error/validation failure (mirror
the existing contains('age', ...) test at Line 1614) so query validation for
containsAny/containsAll on non-array/non-string attributes is enforced
consistently with Query::contains.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Database/Database.php`:
- Around line 9254-9267: The reverse lookup in processNestedRelationshipPath is
incorrectly skipping relationship resolution for many-to-many links, causing
twoWayKey attributes to be absent and empty matchingIds; modify
processNestedRelationshipPath (or the caller convertRelationshipQueries where it
invokes processNestedRelationshipPath) to detect many-to-many relationships and
avoid calling skipRelationships() for those cases (or alternatively perform the
lookup against the junction collection), so that relationship attributes
(including the twoWayKey) are populated when calling find() on relatedCollection
and matchingIds are correctly returned.

---

Nitpick comments:
In `@tests/e2e/Adapter/Scopes/AttributeTests.php`:
- Around line 1687-1723: Add tests that cover containsAll behavior on string
attributes and error paths for non-array/string attributes: add cases calling
Query::containsAll('tv_show', [...]) and Query::containsAll('pref', [...])
verifying expected matches when all substrings are present and non-matches when
any substring is missing; and add negative tests invoking
Query::containsAll('age', [...]) and Query::containsAny('age', [...]) that
assert an error/validation failure (mirror the existing contains('age', ...)
test at Line 1614) so query validation for containsAny/containsAll on
non-array/non-string attributes is enforced consistently with Query::contains.

@abnegate abnegate merged commit 5e49f32 into main Feb 17, 2026
17 of 18 checks passed
@abnegate abnegate deleted the feat-contains-all branch February 17, 2026 11:50
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.

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)

8913-9057: ⚠️ Potential issue | 🟠 Major

Intersect nested-path matches instead of unioning them.

$allMatchingIds is currently built via array_merge, which ORs different nested paths. That makes combined filters like employee.company.name = A AND employee.department.name = B return parents that satisfy either branch. Intersect the ID sets per path group and early-return when the intersection is empty.

💡 Proposed fix
-        $allMatchingIds = [];
+        $allMatchingIds = null;
         foreach ($pathGroups as $path => $queryGroup) {
@@
-            $allMatchingIds = \array_merge($allMatchingIds, $matchingIds);
+            $allMatchingIds = $allMatchingIds === null
+                ? $matchingIds
+                : \array_values(\array_intersect($allMatchingIds, $matchingIds));
+
+            if (empty($allMatchingIds)) {
+                return null;
+            }
         }
 
-        return \array_unique($allMatchingIds);
+        return $allMatchingIds === null ? null : \array_values(\array_unique($allMatchingIds));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Database.php` around lines 8913 - 9057, The bug is that
$allMatchingIds is built with array_merge (union) across $pathGroups causing OR
semantics; change the logic so for the first path set $allMatchingIds =
array_values(array_unique($matchingIds)), and for each subsequent path do
$allMatchingIds = array_values(array_unique(array_intersect($allMatchingIds,
$matchingIds))) to perform an intersection, and if at any point $allMatchingIds
becomes empty return null early; update references where $allMatchingIds is used
later to assume a reindexed array of unique IDs.
🧹 Nitpick comments (1)
tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php (1)

2270-2272: Consider adding defensive cleanup for pre-existing collections.

The generic names brands, products, and tags risk colliding with leftover state from a failed prior run or from testPartialUpdateManyToManyWithStringIdsAndDocuments (which also uses tags). Other tests in this trait (e.g., testManyToManyRelationshipWithArrayOperators at Line 2113) already use try/catch guards for this.

Proposed fix
         // 3-level many-to-many chain: brands <-> products <-> tags
+        try { $database->deleteCollection('brands'); } catch (\Throwable $e) {}
+        try { $database->deleteCollection('products'); } catch (\Throwable $e) {}
+        try { $database->deleteCollection('tags'); } catch (\Throwable $e) {}
+
         $database->createCollection('brands');
         $database->createCollection('products');
         $database->createCollection('tags');

Alternatively, use more specific prefixes (e.g., nested_m2m_brands) to avoid name collisions entirely.

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

In `@tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php` around lines 2270
- 2272, The test creates collections named 'brands', 'products', and 'tags'
which can collide with leftover state; modify the setup in ManyToManyTests
(around the createCollection calls) to defensively clean up or avoid collisions
by either (a) attempting to drop those collections first inside try/catch guards
before calling $database->createCollection('brands'),
$database->createCollection('products'), $database->createCollection('tags') or
(b) rename them to unique test-scoped names (e.g., 'nested_m2m_brands',
'nested_m2m_products', 'nested_m2m_tags') so they don't clash with other tests
like testPartialUpdateManyToManyWithStringIdsAndDocuments or
testManyToManyRelationshipWithArrayOperators.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/Database/Database.php`:
- Around line 8913-9057: The bug is that $allMatchingIds is built with
array_merge (union) across $pathGroups causing OR semantics; change the logic so
for the first path set $allMatchingIds =
array_values(array_unique($matchingIds)), and for each subsequent path do
$allMatchingIds = array_values(array_unique(array_intersect($allMatchingIds,
$matchingIds))) to perform an intersection, and if at any point $allMatchingIds
becomes empty return null early; update references where $allMatchingIds is used
later to assume a reindexed array of unique IDs.

---

Nitpick comments:
In `@tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php`:
- Around line 2270-2272: The test creates collections named 'brands',
'products', and 'tags' which can collide with leftover state; modify the setup
in ManyToManyTests (around the createCollection calls) to defensively clean up
or avoid collisions by either (a) attempting to drop those collections first
inside try/catch guards before calling $database->createCollection('brands'),
$database->createCollection('products'), $database->createCollection('tags') or
(b) rename them to unique test-scoped names (e.g., 'nested_m2m_brands',
'nested_m2m_products', 'nested_m2m_tags') so they don't clash with other tests
like testPartialUpdateManyToManyWithStringIdsAndDocuments or
testManyToManyRelationshipWithArrayOperators.

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.

2 participants

Comments