Skip to content

Use query lib - #823

Open
abnegate wants to merge 771 commits into
mainfrom
feat-query-lib
Open

abnegate wants to merge 771 commits into
mainfrom
feat-query-lib

Conversation

@abnegate

@abnegate abnegate commented Mar 3, 2026 •

Copy link
Copy Markdown
Member

Replaces the string/array database API with utopia-php/query value objects so adapters, schema, permissions, and queries share one typed surface.

Why this approach

Database::VAR_* / INDEX_* / PERMISSION_* and raw attribute arrays could not express the query-lib column, index, and relationship model. Adapters now take Attribute, Index, and Relationship objects; factories (Attribute::string(), Index::key(), Relationship::oneToOne(), …) are the call site. Generic constructors stay for dynamic types.

Follow-ups on this branch after the initial migration:

  • Typed factories for attributes, indexes, and relationships
  • Caret ranges for Utopia Composer constraints (^0.4, ^4.0) instead of asterisk (0.4.*)
  • updateDocument stamps $updatedAt with DateTime::nowAfter($old) so same-millisecond Redis updates stay unique
  • updateDocuments still uses DateTime::now() so bulk patches with unparseable $updatedAt do not throw
  • PHPStan baseline entries that no longer match the Index constructor were removed
  • A join no longer disables aggregate and group-by schema validation wholesale: an alias-qualified attribute has to name an alias the query set declared, matching the select/filter/order validators (Validator\Query\JoinedAttributes)
  • The empty-document cache marker is no longer written when the collection's cache epoch rotated under the read that justified it — saveWithLease leases the document key's own generation, which a rotation never touches, so a schema mutation landing mid-read could negative-cache a collection that by then existed
  • stddev() and variance() are pinned to the population statistic on every adapter (see below)
  • Collection metadata is invalidated under the tenant that reads it, so a cached _metadata entry can no longer outlive its own invalidation under tenant-per-document (see below)
  • Hard cut: Database::createCollection(Collection $collection) only. No string-id overload. Adapter createCollection(string $name, array $attributes = [], array $indexes = []) is unchanged.

_metadata invalidation under tenant-per-document

Under shared tables with tenant per document, every collection-metadata cache invalidation was aimed at a key no reader ever used, so anything cached for a collection definition survived its own invalidation for the full 24h TTL.

createDocument() purges through withDocumentTenant(), which switches to the document's own tenant. A collection definition is the one row createDocument() accepts without a tenant under tenant-per-document — the check at Traits/Documents.php explicitly exempts self::METADATA — so that switch set the tenant to null and getCacheBaseKeys() built the collection key with an empty tenant segment:

rotated by the writer:  …:<ns>::collection:_metadata#epoch
read by every reader:   …:<ns>:7:collection:_metadata#epoch

Two different keys. The epoch the writer rotated and the epoch the readers held were never the same one.

The visible consequences were a stale collection definition after a schema change, and — the one that bit — a negative $empty marker. A getCollection() that legitimately missed once while a project was still provisioning stayed a miss for every later request in that tenant, which surfaces as Collection not found out of anything that reads the collection. createCollection() performs such a read itself as its pre-existence check, so the collection could be unreadable to its own creator immediately after being created; with the fix reverted, createAttribute() straight after createCollection() throws NotFound.

The fix is to keep the adapter's tenant when the document carries none. A document that does carry a tenant still purges under it, which is what the switch exists for (GeneralTests::testSharedTablesTenantPerDocument covers that across the adapter matrix).

This is adapter-independent — it is cache-key arithmetic, not SQL — which is why it reproduced identically on MariaDB, MongoDB and PostgreSQL.

Statistical aggregate contract: population

Bare SQL STDDEV and VARIANCE are not portable. MySQL and MariaDB read both as the population statistic; PostgreSQL reads both as the sample statistic. Over the same four rows that is 67.0238 against 77.3985 — the same query, two different answers depending on the engine underneath. The query library emits the bare function verbatim (Method::Stddev => 'STDDEV'), so the ambiguity has to be resolved in the adapter.

The contract is population. SQL::applyFindFilters() rewrites Method::Stddev to Method::StddevPop and Method::Variance to Method::VarPop before the queries reach the builder, so every adapter emits STDDEV_POP / VAR_POP explicitly. Population was chosen because it is what MySQL and MariaDB already returned, what the query library's own ClickHouse builder already emits, and what leaves the existing stddevPop / varPop expectations unchanged.

stddevSamp() and varSamp() were never ambiguous and are untouched — that is how a caller asks for the sample statistic.

Consumers that branch on the adapter to pick an expected value (for instance an isPostgreSQL() check in a test) can drop the branch and assert the population value on every adapter.

Split out of this PR

The typed surface is the change worth reviewing on its own. Three framework layers that were built on top of it were split into separate stacked PRs; all three have since been closed and are not part of the landing plan:

Nothing in this repository or downstream calls them, so this PR stands alone.

Chain

Landing order, bottom up:

  1. utopia-php/database#823 — the query-lib migration itself
  2. utopia-php/abuse#124, utopia-php/migration#222, utopia-php/monorepo#206 — the schema call sites in the libraries (audit moved to the monorepo, so feat: adapt audit schema to query-lib Attribute and Index VOs audit#133 is closed in favour of monorepo#206)
  3. appwrite/appwrite#11649
  4. appwrite-labs/cloud#5410

Every consumer above pins dev-feat-query-lib through a VCS repository, so this PR has to be tagged before any of them can merge — the monorepo in particular refuses dev-branch pins.

utopia-php/database#968 (nested relationship queries) is not stacked on this branch but overlaps it in Database.php and the Memory/Mongo/Redis/SQL adapters. Agreed order is #823 first, then #968 rebased on top.

Verified

  • All 22 checks green, across the adapter matrix: MariaDB, MySQL, Postgres, SQLite, MongoDB, Memory, Mirror, Pool, Redis, plus the shared-tables and schemaless Mongo lanes
  • Unit tests for Collection-only createCollection (CollectionValidationTest, CollectionModelTest, Mirror, CreateCollectionRace)
  • Two pooled-timeout regression tests in tests/unit/Adapter/PoolTest.php, both seen red before the fix:
    • setTimeout on a pooled handle no longer dials a connection just to answer hasFeature()
    • a timeout raised while pinned reaches a subclass that pins its connection elsewhere, which is what cloud does per coroutine
  • tests/unit/TenantIdentityTest.php pins PHP's tenant comparison to what _tenant INT UNSIGNED actually stores, checked against a real MySQL
  • PHPStan on the Collection-only facade sources, and Pint on the converted files
  • tests/unit/Documents/MetadataTenantInvalidationTest.php — with shared tables and tenant-per-document, a miss cached before provisioning does not outlive the collection being created, and a collection is readable (with the right attribute count) by its own writer after creation and after a schema change. Both cases fail without the fix, the second with NotFound: Collection not found
  • tests/unit/Documents/NegativeCacheEpochTest.php — a miss observed before a concurrent write rotates the epoch is not negative cached, and a miss with no concurrent write still is. Seen red before the fix (a $empty marker for webhooks:hook survived the read)
  • tests/unit/StatisticalAggregateContractTest.php — the SQL each of MySQL, MariaDB and Postgres generates for stddev() / variance() is the _POP form, and stddevSamp() / varSamp() are left alone. Seen red before the fix: Postgres emitted STDDEV("price") (sample) where MySQL emitted the identical text meaning population
  • AggregationTests::testStddevAndVarianceArePopulationOnEveryAdapter — fixed dataset, asserts the population value and asserts it is not the sample value, across the whole adapter matrix

Not verified

  • The join-alias scoping above is covered by unit tests only; the e2e adapter matrix exercises joined aggregation but not an undeclared alias, which is rejected before it reaches an adapter.
  • A bare (unqualified) attribute still bypasses the schema check while a join is present, because the joined collection's schema is not available to the validator. Closing that needs the joined schema threaded through Validator\Queries\Documents and its per-collection cache key — a separate change.

Fixes from the 2026-09-23 review

The sections above describe the branch as it was before the review; where they disagree with what follows, what
follows is current.

The review found regressions against 7.x and defects in the features new in 8.0. The fixes ship with regression tests
that fail without them, and the tests that had been weakened or moved to stubbed unit tests run against real engines
again. Upgrading from 7.x: see UPGRADE.md.

Upgrade notes

  • Hook failures follow 7.x, event by event. The events 7.x dispatched without a try/catch let the first hook
    exception reach the caller and skip the remaining hooks: index_create, document_read, document_create,
    documents_create, document_update, documents_update, documents_upsert, document_increase,
    document_decrease, document_delete, documents_delete, document_find, document_count, document_sum, and
    document_purge from a document write or purgeCachedDocument(). Every other event isolates each hook: its exception
    is swallowed and the remaining hooks still run. An \Error (a TypeError, for example) always reaches the caller.
    Differences from 7.x:
    • 7.x also swallowed an \Error at the isolated events.
    • At an isolated event, 7.x skipped the remaining listeners after the first failure; 8.0 runs them.
    • document_purge from updateDocument() and deleteDocument() fires after the write's transaction has committed.
      A failing purge listener fails the call, and the write stays committed.
  • Bigint attributes are stored as bigint, as in 7.x. ColumnType::BigInteger (value 'biginteger') is the
    in-memory type only. Collection metadata, attribute events and Attribute::toDocument() carry 'bigint', so existing
    metadata needs no migration. Write a stored type with Attribute::persistedType(), and read one with
    Attribute::normalizeType()/tryNormalizeType(), which accept both spellings.
  • Cache key names changed: do not share a cache between 7.x and 8.0 nodes. Document cache keys now include the
    database name ({cacheName}-cache-{hostname}:{database}:{namespace}:{tenant}:collection:{collection}), and purging a
    document advances a per-collection epoch instead of deleting the old key. During a rolling upgrade, a write handled by
    a 7.x node purges only the old keys, so an 8.0 node can serve a stale cached document until the cache TTL. After the
    last 7.x node has stopped, flush the cache (or deploy without overlap).
  • Other changes relative to 7.x (details in UPGRADE.md):
    • Exception\Unique has the message Document with the requested unique attributes already exists on every adapter
      (was Unique index violation). Match on the class: catch Unique before Duplicate.
    • Query::DEFAULT_ALIAS, the main collection's alias in generated SQL, is table_main (was main).
    • Database::ATTRIBUTE_FILTER_TYPES is renamed ATTRIBUTE_FILTER_COLUMN_TYPES and holds ColumnType cases.
    • new Document() and setAttribute('$permissions', …) reject non-string permissions with Exception\Structure
      instead of keeping them. Reading stored data never throws: Document::fromRow() and the new
      Document::fromStorage() drop such entries.
    • An empty attribute format (7.x metadata stores '') reads as null.
    • createCollection() validates attribute types up front, like createAttribute().
    • createAttributes() fires attribute_create once per attribute with a Document payload, then the new
      attributes_create once with the list.
    • A relationship linked through a nested update needs update permission on the linked document.
    • Hook\Read::applyFilters() takes a required PermissionType $forPermission.
    • Validator\Queries\Documents accepts joins and aggregations only with its new supportForJoins and
      supportForAggregations flags; Database sets them from the adapter's capabilities.
    • On MariaDB and MySQL an unknown column throws Exception\NotFound (Attribute not found), as on PostgreSQL.
    • Mirror forwards every configuration setter to its source and destination, and replicates upsertDocument() and
      upsertDocumentsWithIncrease().
    • Session state that must survive a transparent reconnect of Utopia\Database\PDO is set with PDO::configure().
    • A SQLite adapter subclass that overrides createBuilder() returns Utopia\Database\Builder\SQLite.

Documents

  • Document permissions follow every write. The parsed roles were cached and reset only by setAttribute(), so a
    write to $permissions through ArrayAccess, a reference, exchangeArray() or unset left getRead() and its
    siblings returning the old roles. getPermissions() returns a de-duplicated list again.
  • Reads never fail on stored data. MongoDB, Memory and Redis build every read result through
    Document::fromStorage(), so a stored non-string permission is dropped, as the SQL adapters already did, instead of
    failing getDocument(), find(), updateDocument() and updateDocuments().
  • A document id of 'unique()' is stored verbatim again, as in 7.x, including for related documents created
    through relationship attributes. Only an empty id asks the library to generate one.
  • Write paths restored to 7.x behaviour: updateDocuments() with an Operator decodes the refetched batch once
    (decode filters ran twice); count() and sum() on a missing collection throw NotFound; incrementing an unset
    optional number treats it as 0; a case-only $id rename in updateDocument() is applied instead of dropped; and
    updateCollection() validates the metadata it writes.
  • createCollection(), createAttribute() and createAttributes() no longer modify the models passed to them.
    Added filters and adapted index lengths and orders were written back into the caller's objects, so the first create
    in a worker rewrote shared config definitions for every later caller.
  • Redis throws Exception\Unique for unique index violations, like every other adapter.

Lifecycle events

  • testEvents can fail again. triggerHooks() swallowed the assertions the test's recorder made, so the event
    contract passed whatever happened. The test now asserts the whole sequence after the calls.
  • document_purge fires from document writes again (updateDocument(), for the old and the new id on a rename,
    updateDocuments(), upsertDocuments(), increments, decrements, deleteDocument() and deleteDocuments()), once
    per purged document after the write's transaction. Cross-region cache relays depend on it.
  • Named hooks and selective silent() are back. A hook that also implements Hook\Named replaces the hook
    registered under its name, in its position, and silent($callback, ['name']) silences only the named hooks.

Caches

  • The find() query cache is scoped like its entries. Epochs and generations were keyed by collection alone, so
    under shared tables one tenant's write switched every tenant's cache off and then orphaned their cached results. Every
    key now derives from the hostname, database, namespace and tenant.
  • A query cache hit costs 3 cache round trips instead of 22 (a miss 5 instead of 23).
  • purgeCachedQueries() also purges the find() query cache, and listCollections() is never served stale.
  • Custom types stay on the handle that registers them. TypeRegistry::register() installed every type as a
    process-wide filter, so one handle could replace the built-in json filter for every other handle in the worker. A
    Database now resolves filters from its constructor, then its TypeRegistry, then the global filters; the built-in
    filter names are reserved; and cache keys follow each type's class. The unused Embeddable contract and
    Custom::columnType()/columnSize() are removed.
  • The negative document cache records only misses an unfiltered read confirms, so an adapter that filters by
    permission can never publish "not found" for a document another caller may read.

Queries and validation

  • Query validation. Index validation no longer treats a path into an object attribute as a string attribute;
    float counts 8 bytes toward the index length; a json default skips the type check only for an array, object or
    Document; supportUnsignedBigInt defaults to true again; updateDocuments() and deleteDocuments() reject join
    queries; and getDocumentsValidator() takes the joined collections (a list with joins is never cached).
  • Query shape. At most 8 joins per query (MariaDB and MySQL reject more than 61 tables raw). having() conditions
    follow the filter rules and compare an aggregate alias of the same query or a groupBy attribute. sum, avg,
    stddev* and variance/var* need a numeric attribute that is not an array, and bitAnd/bitOr/bitXor an
    integer one; only count accepts *. Validator\Queries with a length caps nested groups again.
  • Empty-set aggregates agree on every engine: count and countDistinct return 0, every other aggregate null.
    MariaDB and MySQL returned 18446744073709551615 or 0 for the bitwise aggregates. Database::sum() still returns
    0 for no rows, as in 7.x.
  • Aggregates never surface a raw engine error. Adapters report Capability::StatisticalAggregates and
    Capability::BitwiseAggregates; SQLite reports neither, and find() rejects those aggregates there. Aggregate
    aliases are at most 63 characters (PostgreSQL's identifier limit). MariaDB and MySQL errors 1116 and 1191 and
    PostgreSQL's DISTINCT/ORDER BY 42P10 (MySQL 3065) map to Exception\Query, and an unknown column (1054) to
    Exception\NotFound.

Joins

  • Join permissions follow direct reads. A joined collection is visible exactly as a direct find() on it would be:
    every row with a collection-level grant, the rows the caller holds document-level read on with document security, and
    Exception\Authorization otherwise. Adding a join never changes which main rows are visible, in find(), count(),
    sum() and getDocument().
  • Joined reads follow direct reads through every chain of joins. Permission conditions are placed where tenant
    conditions are, and a right or full outer join checks the main table's and its own conditions in its ON. An
    unreadable document never hides a row an outer join keeps, and a row whose only match is unreadable comes back
    unmatched, exactly like a row whose match does not exist.
  • Joins are isolated per tenant under shared tables, for every join type and chain. Each table is limited to the
    selected tenant before rows are paired, a later right or full outer join repeats the conditions of the tables joined
    before it, and a row without a tenant is never joined. A full outer join combined with a right join reads what a
    dedicated database reads, emulated and native.
  • Full outer joins on MariaDB, MySQL and SQLite (two halves of a UNION ALL) return each row once: a later right
    join's unmatched rows no longer come back twice. Aggregates, groupBy(), having(), distinct(), ordering and paging
    apply once to the whole joined result, as PostgreSQL's native full outer join does. These engines accept one full
    outer join per query, and reject a right join after it that joins only on a table cross joined after it and a
    distinct() over it ordered by an unselected attribute, with Exception\Query.
  • The full outer join's ordering columns no longer swallow user attributes. They are now prefixed with $, which
    no attribute key can start with; before, an attribute or join alias starting with foj_ord_ read back null.
  • Join aliases can no longer collide. A declared alias must be an identifier, unique without regard to case and not
    Query::DEFAULT_ALIAS; generated aliases skip declared ones. The tenant and permission conditions name every alias
    quoted, so mixed-case and reserved-word aliases work on PostgreSQL and SQLite.
  • A join without a select returns the joined collection's $id and attributes under the alias (alias.$id,
    alias.attribute), never under a bare name, and joined values are decoded like a direct read (decrypted,
    JSON and arrays decoded, datetimes formatted, typed). A cursor taken from a joined result pages correctly.
  • Joined columns are validated. alias.column is valid exactly when column would be valid unaliased on the joined
    collection for that query type, getDocument() validates its join conditions as find() does, and the numeric and
    integer aggregate rules apply to joined attributes. A bare aggregate or groupBy attribute means the main
    collection's attribute, else the one joined collection that declares it; an ambiguous or unknown name is rejected.
    search() on a joined attribute needs a fulltext index on the joined collection.
  • A vector search ordered by a joined attribute pages with a cursor on PostgreSQL.

Shared tables and permissions

  • Upserts store their permission rows under the tenant. upsertDocuments() never registered the tenant write hook,
    and Adapter\Pool made that the normal case, so upserted documents were invisible to document-level readers on
    MariaDB, MySQL and SQLite.
  • Under tenant-per-document, an upsert revokes a permission under the upserted document's own tenant on MariaDB,
    MySQL, PostgreSQL and SQLite, as 7.x did.
  • _metadata reads are permission-filtered again, as in 7.x: listCollections() and find(Database::METADATA)
    agree with count(Database::METADATA). Definitions created without a tenant are listed from every tenant of a pool.
  • Database::from() and execute() are a raw escape hatch that needs authorization disabled (inside
    getAuthorization()->skip()). Skipping authorization lifts permissions, never tenant isolation: under shared tables
    every statement from a from() builder stays in the tenant selected when it was handed out.
  • SQLite index names use the filtered tenant again, as the existence probe and deleteIndex() do.

MongoDB

  • Permissions apply only to reads, and always while authorization is enabled. find(), count() and sum()
    filter by $permissions whether or not Hook\Permissions is registered; writes and getDocument() are scoped by
    tenant only, as in 7.x. Writes authorized through update or delete permission no longer silently do nothing when the
    caller cannot also read the document.
  • getSequences() is tenant-safe, listCollections() returns only the definitions the caller may read, and the
    MongoDB test classes override the trait tests they claim to (the snake_case stubs never did).

SQL adapters

  • MariaDB and MySQL timeouts survive a transparent reconnect. Utopia\Database\PDO replays session settings made
    through the new PDO::configure() on the new connection before it retries the call.
  • Lost connections are recognised by driver error code (MySQL/MariaDB 1053, 2002, 2006, 2013, 4031; SQLSTATE class
    08; PostgreSQL 57P01–57P05), so MySQL 8.0.24+'s idle disconnect is reconnected without Swoole's library.
  • setMetadata() reaches the database again. Every prepared statement starts with one /* key: value */ comment per
    entry, ahead of the registered transforms, and comment delimiters, control characters and invalid UTF-8 in keys and
    values are neutralised.
  • SQLite pattern queries match _, % and \ literally again: the new Utopia\Database\Builder\SQLite declares
    ESCAPE '\' on every LIKE.
  • Spatial columns and composite indexes match 7.x. Batch createAttributes() works for spatial attributes on
    MariaDB, required spatial attributes on PostgreSQL are nullable columns again, and composite indexes keep the caller's
    column order.

Attributes and types

  • One table of storable attribute types (Attribute::TYPES), which every layer now agrees with. Types an adapter
    could create but never write or update (timestamp, serial, smallserial) are rejected up front, unknown types fail
    with one message everywhere, and updateAttribute() accepts id attributes and refuses relationship attributes.
  • Typed attribute factories and classes that no adapter can store are removed: tinyInteger(), smallInteger(),
    decimal(), timestamp(), json(), binary(), enum(), uuid(), uuid7(), serial(), bigSerial(),
    smallSerial(), array() and tuple(). None shipped in a release.
  • Increments and numeric operators accept integer, bigint, float and double only.
  • Relaxing required runs before the metadata write, so a failed DROP NOT NULL on PostgreSQL leaves the stored
    definition untouched.

Relationships

  • Cascade and set-null deletes are chunked, so a cascade with more related documents than getMaxQueryValues()
    no longer orphans every child. A failed cascade can no longer make a retry skip it.
  • Permission shortfalls throw and roll back, as in 7.x, instead of leaving orphans or reporting a link that was not
    written. Relinking unchanged children needs only read permission again.

Pools, Mirror and profiling

  • ReadWritePool reads your own writes and uses its replicas. Reads stay on the primary for the sticky window after
    a write or transaction commits; locking reads and rawQuery() go to the primary; and metadata and configuration calls
    (getHostname(), capability checks, value casting) no longer re-open the window, so replicas are actually read.
  • QueryProfiler keeps the newest 1000 entries (setCapacity() to change), and pooled connections no longer keep
    the profiler of the handle that last borrowed them.
  • Mirror invalidates the query cache it serves, forwards every configuration setter to its source and
    destination, opens scoped setters on the source where its writes run, and replicates upsertDocument() and
    upsertDocumentsWithIncrease() with their events fired once.

Composer

  • utopia-php/database: 8.* resolves before 8.0.0 is tagged. extra.branch-alias maps dev-feat-query-lib and
    dev-main to 8.0.x-dev. The VCS repositories are gone and minimum-stability is stable again; the lock changes
    only its provenance. Before the tag, require 8.* with minimum-stability: dev and prefer-stable: true in the root
    composer.json.

Tests restored

  • Engine-level e2e tests that had been moved to stubbed unit tests run on real engines again: vector search with
    permissions, object attribute queries and defaults, spatial defaults and index rules, schemaless internal attributes
    and TTL indexes, and six relationship tests (array operators on many-to-many and one-to-many, nested permission
    enforcement, empty values, no-op nested updates).
  • Weakened expectations are restored: testGetDocumentSelect, the PostgreSQL long-name precondition,
    testTransformations (it now proves setMetadata() reaches the SQL), testCacheFallbackOnFailure (the write path
    during a cache outage), testLabels, testTrigramIndexValidation, testTTLIndexDuplicatePrevention, and the
    Unique assertions of testDuplicateExceptionMessages, testUniqueIndexDuplicate and
    testUniqueIndexDuplicateUpdate.
  • New coverage for joins under shared tables and document security: every join type and chain of two joins against a
    dedicated database and against direct reads, emulated and native full outer joins, and aggregates over a full outer
    join with unreadable rows.

Wave 2: fixes and cleanups after the review

Documents and relationships

  • Every _metadata write runs with validation on again, as in 7.x. The Structure check of the collection
    document and the query validation of its nested read run in updateMetadata(), createRelationship() (including
    its rollback) and deleteRelationship(); the library no longer calls skipValidation() internally. A collection
    document that fails validation fails the schema change: the attribute and index methods and deleteRelationship()
    throw Utopia\Database\Exception with the Structure exception as its previous, and createRelationship() throws
    Failed to create relationship: ….
  • A cascade below the first level deletes each related document through deleteDocument(), as in 7.x. The
    bulk delete it used selected its batch under the caller's read permission, so a related document the caller could
    not read was left behind with everything below it and its own Restrict relationships were never checked.
    deleteDocument() loads the document without reading it, checks only the delete permission and cascades below
    it, so an unreadable grandchild is deleted with the rest, blocks the delete under Restrict, or rolls the delete
    back when the caller may not delete it.
  • bigserial is refused when createAttribute()/createAttributes() adopt an orphan column. That recovery path
    skips the attribute validator and described the column with getColumnType(), which still mapped bigserial, so
    over a column that exists in the schema but not in the metadata it was accepted and written to the metadata. It
    fails like the other unstorable types, as on 7.x. The remaining handling for the fourteen unstorable types is gone
    with it.

MongoDB

  • Under shared tables, an upsert writes each document under its own tenant. With tenant-per-document, the
    upsert filtered by the selected tenant and wrote no _tenant, so a document of another tenant landed under the
    selected tenant, or under none. The document's tenant is stamped, falling back to the selected one, and matched,
    as createDocuments() already did and as 7.x did.

Aggregations, distinct and search

  • A select in an aggregation query names only what the query groups by. An aggregation query (an aggregate or a
    groupBy()) returns one row per group, holding the groups and the aggregates. A select() next to them may name
    only an attribute the query groups by; any other select throws Utopia\Database\Exception\Query
    (Cannot select "<attribute>": an aggregation query can only select the attributes it groups by). * and
    relationship wildcards at any depth are accepted and ignored, so appwrite's V20 request filter, which adds them to
    every read below response format 1.8.0, keeps working; a join alias's alias.* is rejected. A bare groupBy name
    that only one join declares is that join's column, so groupBy(['score']) with select(['note.score']) is
    grouped. No select reaches an aggregation's statement. Before, MySQL answered an ungrouped select with a raw 1140,
    PostgreSQL with 42803 (a 500 through appwrite), and MariaDB and SQLite returned an arbitrary row's value.

  • An order in an aggregation query names an aggregate alias or a grouped attribute. Any other order throws
    Utopia\Database\Exception\Query (Cannot order by "<attribute>": …) instead of failing in the engine
    (PostgreSQL 42803, MySQL 1055) or returning an arbitrary order (MariaDB, SQLite). orderRandom() is accepted.

  • Internal attributes group the joined rows under a join alias. groupBy(['note.$id']), and the same for
    $sequence, $createdAt, $updatedAt, $permissions and, under shared tables, $tenant, return their groups
    over every join. They used to fail in the engine (no such column: note.$id) everywhere but the emulated full
    outer join.

  • A main attribute aggregated under its own name reads the main table. sum('score', 'score') over a join whose
    collection also declares score failed as an ambiguous column. Every bare main-collection aggregate argument is
    qualified with the main alias when a query joins.

  • $collection and $tenant are read only where a table holds them. Aggregates and groupBy() reject
    $collection (a read derives it); $tenant in aggregates, groups and joined selects is rejected without shared
    tables and works with them.

  • Join conditions compare columns their tables have, in both the flat and the on() form: the left column
    belongs to the main collection or to a join declared before, the right column to the joined collection, and a
    relationship side without a column is rejected (Cannot join on virtual relationship attribute: <key>). The
    operator check applies to the flat form too.

  • Database::sum() validates its attribute as a sum aggregate does, and an encrypted attribute of a joined
    collection cannot be filtered under its alias, as on the main collection.

  • Each aggregate alias names one column of the result. An alias equal to the name a group comes back under, or
    to another aggregate's alias, is rejected; the row used to hold only one of the two values or the statement failed.
    Bitwise aggregates on PostgreSQL name their input count $inputs:<n>, which no engine truncates.

  • Aggregation and distinct reads next to a search() or a vector query run on every engine. The relevance
    projection and order, and the vector distance order, were added to every read without an explicit order, so an
    aggregate next to a search failed with a raw 1064 (MariaDB, MySQL) or 42803 (PostgreSQL), a groupBy() returned
    an extra _relevance column, a distinct() read returned each selection once per relevance value, and a distinct
    read next to a vector query failed on PostgreSQL (42P10). An aggregation query returns its groups and aggregates
    only; a distinct read returns each selection once, ordered by its explicit orders, with the ordinary cursor.
    Vector distance orders row reads only.

  • A search() read paged with a cursor lists every match once, and a search only filters, as in 7.x. Without an
    explicit order the read was ordered by relevance while the cursor compared $sequence, so pages skipped and
    repeated rows. No read is ordered by relevance by itself, rows carry no _relevance, and a search read is ordered
    by its explicit orders and then $sequence. getSearchRelevanceRaw() (new in the PR) and
    Postgres::getFulltextValue() are removed; the query builders normalize search terms.

  • A distinct() read whose select names only joined columns returns each of them once, under its alias, and
    accepts a joined internal attribute (alias.$id). The projection replaced the select, but the caller's select was
    forwarded as well and compiled raw, which duplicated the columns and put a joined value on the main document's
    bare key.

  • A filter on a joined column is checked against the joined collection's attribute. The joined-column branch of
    the filter validator checked only the value count, so equal('book.pages', ['abc']) reached PostgreSQL as 22P02,
    a bad date as 22007, startsWith on an integer as 42883 (all 500 through appwrite), and a nested list as
    Unknown PDO Type for array on every engine. The validator now applies the main collection's type, size, array and
    contains rules to the joined attribute, and rejects vector queries on joined attributes, which no adapter compiles.

Query cache

  • Under tenant-per-document, a write refreshes the query cache of each written document's tenant. find()
    caches its results per tenant, but a write invalidated only the scope of the tenant selected on the writing
    Database, so a written tenant's cached results stayed in place until the region TTL: new and changed rows stayed
    invisible to its readers, and a reader whose grant an upsert had just revoked was still served the document. The
    tombstones and epochs now cover each written document's collection in the scope of its tenant (its own, else the
    selected one), and every other tenant's cached results stay in place.
  • A stored document's own attributes never name a collection to invalidate. The invalidator reads
    options.relatedCollection only for attribute events, so a document with an attribute called options refreshes
    its own collection and nothing else.

Removed code

  • The unreachable SQL condition builders (getSQLConditionsForCollection() and everything behind it, about 800
    lines across the SQL adapters), Utopia\Database\Loading\* and Utopia\Database\Traits\Async are removed.
    Nothing wired them in. utopia-php/async stays a dependency of Mirror and the relationship hook.

Tests

  • The suite fails on warnings, notices, deprecations and risky tests again. PHPUnit 10 removed the
    convert*ToExceptions attributes and the PHPUnit 12 configuration had no replacement, so such issues were counted
    but never failed a run. phpunit.xml now sets failOnWarning, failOnNotice, failOnDeprecation, failOnRisky,
    failOnPhpunitNotice and failOnPhpunitDeprecation, scoped to src/ and tests/; paratest computes the CI
    lanes' exit code from the same settings. Fixed at the source while turning this on: SpatialFilterTest's
    expectation-less mock, PoolTest's setAccessible(), 17 e2e calls of the deprecated Query::contains(), and an
    alias-length test on the lanes without aggregations.

Documentation

  • UPGRADE.md walks a 7.x consumer through every change with the two forms side by side, CHANGELOG.md lists the
    release with the fixes to the 8.0 pre-releases in their own subsection, and README.md describes the 8.0 API.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Mar 3, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Large refactor: Query delegates to utopia-php/query BaseQuery; adapters, traits, hooks, and new domain types/enums (Attribute, Index, Relationship, Capability, etc.) migrate to value-object and capability models; CI, Docker, and composer updated to use a local query path and Paratest.

Changes

Cohort / File(s) Summary
Composer & CI
composer.json, .github/workflows/tests.yml, Dockerfile, docker-compose.yml
Add local utopia-php/query path repo, set minimum-stability: dev + prefer-stable, add brianium/paratest, switch CI to Paratest (4 processes), update Docker/build contexts and mounts for local query source.
Core Query
src/Database/Query.php
Make Query extend external BaseQuery, map old TYPE_* to enum-backed Method values, delegate parse/toArray and wrap exceptions.
Domain Value Objects
src/Database/Attribute.php, src/Database/Index.php, src/Database/Relationship.php
Add Attribute, Index, Relationship classes with toDocument/fromDocument and constructors; shift APIs to accept these objects.
Enums / Operators
src/Database/Capability.php, CursorDirection.php, OrderDirection.php, PermissionType.php, RelationType.php, RelationSide.php, SetType.php, OperatorType.php, src/Database/Operator.php
Introduce many enums (Capability, CursorDirection, OrderDirection, PermissionType, RelationType, RelationSide, SetType, OperatorType); refactor Operator to use OperatorType and remove string-constant groups.
Adapter Features & Surface
src/Database/Adapter.php, src/Database/Adapter/*, src/Database/Adapter/Feature/*
Introduce Feature interfaces (Attributes, Collections, Documents, Indexes, Relationships, Upserts, Timeouts, Transactions, Spatial, etc.), add supports()/capabilities(), add write hooks and row decoration, and change many adapter method signatures to use Attribute/Index/Relationship and enum types.
Adapter Implementations
src/Database/Adapter/MariaDB.php, MySQL.php, Mongo.php, SQLite.php, Pool.php
Large rewrites: builder-driven SQL, capability-driven logic, tenant/permission hooks, RetryClient for Mongo, upsert/index/attribute handling migrated to object/enums; many public signatures updated.
Traits: Collections/Documents/Indexes/Attributes/Relationships/Transactions
src/Database/Traits/*
Add comprehensive trait implementations for collection, document, index, attribute, relationship, and transaction lifecycles with validation, rollback, hooks, and events.
Hooks & Pipeline
src/Database/Hook/*
Add Read/Write hook interfaces and implementations (TenantWrite, PermissionWrite, MongoTenantFilter, MongoPermissionFilter, PermissionFilter, TenantFilter, RelationshipHandler, Relationship contract, WriteContext) to support tenanting, permissions, relationships and write decoration.
Permissions & Helpers
src/Database/Helpers/Permission.php, src/Database/Document.php
Switch permission constants to PermissionType enum values; Document::setAttribute now accepts SetType enum.
Utilities & Mirror/Pool
src/Database/Adapter/Mongo/RetryClient.php, src/Database/Pool.php, src/Database/Mirror.php
Add RetryClient wrapper for Mongo, update Pool and Mirror to accept domain objects and delegate capabilities/supports, propagate tenant settings.
Docs & Tasks
bin/tasks/relationships.php, README.md
Replace old onDelete/set-type constants with ForeignKeyAction and SetType enum value usages in tasks and README examples.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Pool
    participant Adapter
    participant WriteHooks as "Write Hooks\n(PermissionWrite/TenantWrite)"
    participant Relationship as "RelationshipHandler"
    Client->>Pool: createDocument(collection, Document)
    Pool->>Adapter: createDocument(Document)
    Note right of Adapter: decorate row via write hooks
    Adapter->>WriteHooks: decorateRow(row, metadata)
    WriteHooks-->>Adapter: decorated row
    Adapter->>Adapter: persist row (DB)
    Adapter->>Relationship: afterDocumentCreate(collection, document)
    Relationship-->>Adapter: possibly update/populate related docs
    Adapter-->>Pool: Document (created)
    Pool-->>Client: Document (created)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Feat relationship updates #724 — relationship subsystem changes and relationship task updates (strong overlap with RelationshipHandler, Traits, and adapter relationship changes).
  • Upsert fix for postgres #613 — upsert-related adapter and API changes (overlaps with upsertDocuments and adapter upsert support).
  • Add operators base #713 — operator and OperatorType infrastructure (overlaps with Operator/OperatorType refactor and adapter operator handling).

Suggested reviewers

  • ArnabChatterjee20k
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-query-lib

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

62-76: Align cursor helper docblocks with the actual parameter type.

Lines 62 and 73 document Document, but the signature is mixed $value (Lines 65 and 76). Please update phpdoc to match actual behavior.

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

In `@src/Database/Query.php` around lines 62 - 76, The PHPDoc for
Query::cursorAfter and Query::cursorBefore incorrectly types the parameter as
Document while the method signatures accept mixed $value; update the `@param`
annotations in both docblocks to "@param mixed $value" (and keep "@return Query"
unchanged) so the docblocks match the actual signatures for the cursorAfter and
cursorBefore methods on the Query class.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@composer.json`:
- Around line 62-66: Replace the SSH Git URL in the composer.json "repositories"
entry with its HTTPS equivalent to avoid CI SSH auth issues; locate the
"repositories" array and update the url value currently set to
"[email protected]:utopia-php/query.git" to use
"https://github.com/utopia-php/query.git" so installs succeed in non-interactive
CI environments.

In `@src/Database/Query.php`:
- Around line 167-183: The code is using $limit as a fallback for both $offset
and $cursor which is incorrect; update the assignments in the TYPE_OFFSET and
TYPE_CURSOR_AFTER/TYPE_CURSOR_BEFORE branches so they use the actual first value
or null instead of $limit (i.e. change $values[0] ?? $limit to $values[0] ??
null) while keeping the existing checks that ignore subsequent offsets/cursors
and preserving $cursorDirection assignment (references: TYPE_OFFSET,
TYPE_CURSOR_AFTER, TYPE_CURSOR_BEFORE, $offset, $cursor, $values, $limit,
$cursorDirection, Database::CURSOR_AFTER, Database::CURSOR_BEFORE).

---

Nitpick comments:
In `@src/Database/Query.php`:
- Around line 62-76: The PHPDoc for Query::cursorAfter and Query::cursorBefore
incorrectly types the parameter as Document while the method signatures accept
mixed $value; update the `@param` annotations in both docblocks to "@param mixed
$value" (and keep "@return Query" unchanged) so the docblocks match the actual
signatures for the cursorAfter and cursorBefore methods on the Query class.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ba1ee9c and 44c19f9.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • composer.json
  • src/Database/Query.php

Comment thread composer.json Outdated
Comment thread src/Database/Query.php Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

161-177: ⚠️ Potential issue | 🟠 Major

Fix fallback leakage from limit into offset/cursor state.

Line 167 and Line 176 incorrectly use $limit as fallback, which can corrupt grouped query output when offset/cursor values are missing.

🔧 Proposed fix
                 case self::TYPE_OFFSET:
@@
-                    $offset = $values[0] ?? $limit;
+                    $offset = $values[0] ?? $offset;
                     break;
@@
                 case self::TYPE_CURSOR_AFTER:
                 case self::TYPE_CURSOR_BEFORE:
@@
-                    $cursor = $values[0] ?? $limit;
+                    $cursor = $values[0] ?? $cursor;
                     $cursorDirection = $method === self::TYPE_CURSOR_AFTER ? Database::CURSOR_AFTER : Database::CURSOR_BEFORE;
                     break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Query.php` around lines 161 - 177, In Query:: (switch handling
TYPE_OFFSET / TYPE_CURSOR_AFTER / TYPE_CURSOR_BEFORE) remove the incorrect
fallback to $limit so $offset and $cursor aren't polluted: instead of assigning
$values[0] ?? $limit, only assign when an explicit value exists (e.g. check
isset($values[0]) or array_key_exists) and otherwise leave $offset/$cursor as
null; keep the first-occurrence guards intact and preserve setting
$cursorDirection when handling TYPE_CURSOR_AFTER vs TYPE_CURSOR_BEFORE
(Database::CURSOR_AFTER / Database::CURSOR_BEFORE).
🧹 Nitpick comments (1)
src/Database/Query.php (1)

53-73: Align cursor helper PHPDoc with the actual mixed parameter type.

Both docblocks still document @param Document $value, but the signatures now accept mixed.

📝 Suggested doc fix
-     * `@param` Document $value
+     * `@param` mixed $value
@@
-     * `@param` Document $value
+     * `@param` mixed $value
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Query.php` around lines 53 - 73, Update the PHPDoc for the Query
helper methods to match the actual parameter type: in the Query class update the
docblocks for cursorAfter and cursorBefore so the `@param` annotation is "@param
mixed $value" (instead of "@param Document $value") and keep the `@return`
annotation as "Query"; ensure these docblocks sit immediately above the
corresponding methods cursorAfter and cursorBefore to maintain accurate
IDE/typehinting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/Database/Query.php`:
- Around line 161-177: In Query:: (switch handling TYPE_OFFSET /
TYPE_CURSOR_AFTER / TYPE_CURSOR_BEFORE) remove the incorrect fallback to $limit
so $offset and $cursor aren't polluted: instead of assigning $values[0] ??
$limit, only assign when an explicit value exists (e.g. check isset($values[0])
or array_key_exists) and otherwise leave $offset/$cursor as null; keep the
first-occurrence guards intact and preserve setting $cursorDirection when
handling TYPE_CURSOR_AFTER vs TYPE_CURSOR_BEFORE (Database::CURSOR_AFTER /
Database::CURSOR_BEFORE).

---

Nitpick comments:
In `@src/Database/Query.php`:
- Around line 53-73: Update the PHPDoc for the Query helper methods to match the
actual parameter type: in the Query class update the docblocks for cursorAfter
and cursorBefore so the `@param` annotation is "@param mixed $value" (instead of
"@param Document $value") and keep the `@return` annotation as "Query"; ensure
these docblocks sit immediately above the corresponding methods cursorAfter and
cursorBefore to maintain accurate IDE/typehinting.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 44c19f9 and d942f2b.

📒 Files selected for processing (3)
  • phpstan.neon
  • src/Database/Query.php
  • stubs/Query.stub

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

159-169: ⚠️ Potential issue | 🟠 Major

Fix incorrect fallback variable usage for offset and cursor.

Lines 159 and 168 incorrectly fall back to $limit instead of the appropriate variable. This can silently corrupt the grouped query state.

  • Line 159: $offset = $values[0] ?? $limit should use $offset (or null)
  • Line 168: $cursor = $values[0] ?? $limit should use $cursor (or null)
🔧 Proposed fix
                 case self::TYPE_OFFSET:
                     // Keep the 1st offset encountered and ignore the rest
                     if ($offset !== null) {
                         break;
                     }

-                    $offset = $values[0] ?? $limit;
+                    $offset = $values[0] ?? null;
                     break;
                 case self::TYPE_CURSOR_AFTER:
                 case self::TYPE_CURSOR_BEFORE:
                     // Keep the 1st cursor encountered and ignore the rest
                     if ($cursor !== null) {
                         break;
                     }

-                    $cursor = $values[0] ?? $limit;
+                    $cursor = $values[0] ?? null;
                     $cursorDirection = $method === self::TYPE_CURSOR_AFTER ? Database::CURSOR_AFTER : Database::CURSOR_BEFORE;
                     break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Query.php` around lines 159 - 169, The switch handling in
Query.php incorrectly falls back to $limit when assigning $offset and $cursor,
corrupting grouped query state; update the assignments in the cases that set
$offset and $cursor (the branches that assign $offset = $values[0] ?? $limit and
$cursor = $values[0] ?? $limit) to use a proper null fallback instead (e.g.
$offset = $values[0] ?? null and $cursor = $values[0] ?? null), keeping the
surrounding logic for cursorDirection (Database::CURSOR_AFTER /
Database::CURSOR_BEFORE) intact.
🧹 Nitpick comments (1)
src/Database/Query.php (1)

54-65: Consider tightening parameter type hint.

The PHPDoc indicates @param Document $value, but the signature uses mixed. Consider using Document as the type hint for better type safety, or update the PHPDoc to reflect the actual accepted types.

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

In `@src/Database/Query.php` around lines 54 - 65, The PHPDoc for cursorBefore
indicates the parameter is a Document but the method signature (and cursorAfter)
use mixed; update the signatures to use Document instead of mixed for
cursorBefore and cursorAfter (i.e., change the parameter type from mixed to
Document) to match the PHPDoc and improve type safety, or alternatively update
the PHPDoc to reflect mixed if these methods truly accept other types—adjust the
declarations in the Query class (cursorBefore, cursorAfter) so the docblock and
method signatures are consistent.
🤖 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/Query.php`:
- Around line 42-49: The method parseQuery currently declares a return type of
static but calls parent::parseQuery() which returns BaseQuery; change the method
signature to return BaseQuery instead of static and keep the try/catch that
wraps BaseQueryException into QueryException (i.e., update the return type on
parseQuery to BaseQuery to match parent::parseQuery and ensure the thrown
QueryException still wraps the original BaseQueryException).

---

Duplicate comments:
In `@src/Database/Query.php`:
- Around line 159-169: The switch handling in Query.php incorrectly falls back
to $limit when assigning $offset and $cursor, corrupting grouped query state;
update the assignments in the cases that set $offset and $cursor (the branches
that assign $offset = $values[0] ?? $limit and $cursor = $values[0] ?? $limit)
to use a proper null fallback instead (e.g. $offset = $values[0] ?? null and
$cursor = $values[0] ?? null), keeping the surrounding logic for cursorDirection
(Database::CURSOR_AFTER / Database::CURSOR_BEFORE) intact.

---

Nitpick comments:
In `@src/Database/Query.php`:
- Around line 54-65: The PHPDoc for cursorBefore indicates the parameter is a
Document but the method signature (and cursorAfter) use mixed; update the
signatures to use Document instead of mixed for cursorBefore and cursorAfter
(i.e., change the parameter type from mixed to Document) to match the PHPDoc and
improve type safety, or alternatively update the PHPDoc to reflect mixed if
these methods truly accept other types—adjust the declarations in the Query
class (cursorBefore, cursorAfter) so the docblock and method signatures are
consistent.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d942f2b and b0a1faf.

📒 Files selected for processing (1)
  • src/Database/Query.php

Comment thread src/Database/Query.php Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

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

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

232-243: ⚠️ Potential issue | 🟡 Minor

Update docblock to match the new parameter type.

The docblock still documents @param string $type but the actual parameter type is now SetType. This could confuse IDE autocompletion and static analysis tools.

📝 Proposed fix
     /**
      * Set Attribute.
      *
      * Method for setting a specific field attribute
      *
      * `@param` string $key
      * `@param` mixed $value
-     * `@param` string $type
+     * `@param` SetType $type
      *
      * `@return` static
      */
     public function setAttribute(string $key, mixed $value, SetType $type = SetType::Assign): static
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Document.php` around lines 232 - 243, Update the docblock for
the setAttribute method to match the new parameter type: replace the incorrect
"@param string $type" with "@param SetType $type" (or fully qualified
\Namespace\SetType if necessary) so IDEs and static analyzers reflect the actual
signature of setAttribute(string $key, mixed $value, SetType $type =
SetType::Assign) and keep the rest of the description intact.
src/Database/Mirror.php (1)

398-422: ⚠️ Potential issue | 🟠 Major

Use the filtered key when mirroring attribute renames.

beforeUpdateAttribute() can rewrite the attribute document, but the destination update still uses the caller’s raw $newKey. Any filter that renames destination attributes will drift on update.

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

In `@src/Database/Mirror.php` around lines 398 - 422, The destination update call
is still using the original caller $newKey even though
writeFilters->beforeUpdateAttribute may have rewritten the attribute document
(including renaming the key); update the call in Mirror:: where updateAttribute
is invoked to use the filtered key from the mutated $document (e.g.,
$document->getAttribute('key')) instead of $newKey, falling back to $newKey if
the document does not contain a key, so attribute renames applied by
beforeUpdateAttribute are honored; reference writeFilters,
beforeUpdateAttribute, $document, updateAttribute and $newKey when making the
change.
src/Database/Adapter/Pool.php (1)

101-104: ⚠️ Potential issue | 🟠 Major

Persist the timeout on the pool wrapper.

This override forwards the current call but never updates $this->timeout. Later delegate() calls therefore won’t reapply the timeout to newly borrowed adapters, so the setting is lost across pool checkouts.

⏱️ Proposed fix
 public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void
 {
+    parent::setTimeout($milliseconds, $event);
     $this->delegate(__FUNCTION__, \func_get_args());
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Pool.php` around lines 101 - 104, The setTimeout
override in Pool::setTimeout(int $milliseconds, string $event =
Database::EVENT_ALL) forwards the call to delegate() but fails to persist the
value to the pool wrapper, so $this->timeout is never updated and newly borrowed
adapters don't get the timeout; update the Pool::setTimeout implementation to
assign the passed $milliseconds (and $event if you store per-event) to the
pool's internal property ($this->timeout or a per-event map) before or after
calling $this->delegate(__FUNCTION__, func_get_args()), ensuring future
delegate() calls reapply the stored timeout to adapters checked out from the
pool.
src/Database/Query.php (1)

240-257: ⚠️ Potential issue | 🟠 Major

Cursor queries stop working after toArray() / groupForDatabase() round-trips.

Documents::find() expects $cursor to be a Document so it can read order keys and $collection. toArray() collapses cursor documents to $id, and groupForDatabase() returns that scalar unchanged, so any serialized cursor query will fail when pagination code calls getAttribute() or getCollection() on it.

🔧 Proposed fix
-                if ($value instanceof Document && in_array($this->method, [Method::CursorAfter, Method::CursorBefore])) {
-                    $value = $value->getId();
+                if ($value instanceof Document && \in_array($this->method, [Method::CursorAfter, Method::CursorBefore], true)) {
+                    $value = $value->getArrayCopy();
                 }
                 $array['values'][] = $value;
             }
         }
@@
+        $cursor = $grouped->cursor;
+        if (\is_array($cursor)) {
+            $cursor = new Document($cursor);
+        }
+
         return [
             'filters' => $filters,
             'selections' => $selections,
             'limit' => $grouped->limit,
             'offset' => $grouped->offset,
             'orderAttributes' => $grouped->orderAttributes,
             'orderTypes' => $orderTypes,
-            'cursor' => $grouped->cursor,
+            'cursor' => $cursor,
             'cursorDirection' => $cursorDirection,
         ];

Also applies to: 280-317

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

In `@src/Database/Query.php` around lines 240 - 257, The cursor-serialization code
in Query::toArray() currently replaces Document instances with their id for
cursor methods (Method::CursorAfter, Method::CursorBefore), which breaks
Documents::find() that expects full Document objects after groupForDatabase()
round-trips; change the branch so that when $value instanceof Document you
serialize it with $value->toArray() (not ->getId()) so the document structure is
preserved and can be rehydrated on read (also apply the same fix in the
analogous block around lines 280-317), making sure
groupForDatabase()/unserialize logic continues to round-trip these document
arrays back into Document objects so getAttribute() and getCollection() still
work.
src/Database/Adapter/Mongo.php (2)

1575-1592: ⚠️ Potential issue | 🟠 Major

Keep replacement-style updates for schemaless collections.

This now always wraps the payload in $set. When defined attributes are disabled, missing keys are supposed to disappear on update; with $set they persist forever because the old document is never replaced.

Suggested fix
             $options = $this->getTransactionOptions();
-            $updateQuery = [
-                '$set' => $record,
-            ];
+            $updateQuery = $this->supportForAttributes
+                ? ['$set' => $record]
+                : $record;
             $this->client->update($name, $filters, $updateQuery, $options);

Based on learnings: 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.

🤖 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 1575 - 1592, The updateDocument
method currently always wraps the payload in a $set which prevents
replacement-style updates for schemaless collections; change updateDocument so
that after preparing $record (and unsetting '_id') it checks
$this->getSupportForAttributes(): if true keep $updateQuery = ['$set' =>
$record], but if false use $updateQuery = $record (a replacement document)
before calling $this->client->update($name, $filters, $updateQuery, $options) so
missing keys are removed for schemaless collections; refer to updateDocument and
getSupportForAttributes() to locate where to branch.

1542-1553: ⚠️ Potential issue | 🟠 Major

Scope the post-insert readback.

In shared-table mode _uid is only unique within _tenant. This follow-up find() reads back by _uid alone, so a custom ID collision can hydrate the newly created document from another tenant's row instead of the one that was just inserted.

🤖 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 1542 - 1553, The post-insert
readback in insertDocument uses a filter of ['_uid' => $document['_uid']], which
can collide across tenants; update the filter used in the inner client->find
call to scope by both '_uid' and '_tenant' (e.g. include '_tenant' =>
$document['_tenant'] when present) so the find() returns the document from the
same tenant that was just inserted, adjusting any variable names (filters) used
around client->find accordingly.
🟠 Major comments (19)
composer.json-64-72 (1)

64-72: ⚠️ Potential issue | 🟠 Major

Path repository will fail in CI environments.

The path repository pointing to ../query is only valid for local development where the sibling query directory exists. In CI environments (GitHub Actions, etc.) and for other contributors cloning this repo, this path won't exist and composer install will fail.

For CI compatibility, either:

  1. Publish utopia-php/query to Packagist before merging
  2. Use the official VCS repository URL as a fallback
🔧 Proposed fix using VCS repository
     "repositories": [
         {
             "type": "path",
             "url": "../query",
             "options": {
                 "symlink": true
             }
-        }
+        },
+        {
+            "type": "vcs",
+            "url": "https://github.com/utopia-php/query.git"
+        }
     ],

Note: Composer will prefer the path repository if available, falling back to VCS otherwise.

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

In `@composer.json` around lines 64 - 72, The current "repositories" entry uses a
path repo ("type": "path", "url": "../query") which will break CI because the
sibling ../query won't exist; update composer.json to remove or guard the
path-only repository and add a fallback VCS repository entry pointing to the
public repo URL for utopia-php/query (or publish to Packagist and use its
package name), so Composer can use the VCS source in CI while still preferring
the local path when present; modify the "repositories" array to include the
official VCS URL (or switch to Packagist) alongside or instead of the existing
path entry.
src/Database/Hook/MongoPermissionFilter.php-26-27 (1)

26-27: ⚠️ Potential issue | 🟠 Major

Escape regex metacharacters in role names to prevent regex injection.

Role names are directly interpolated into the regex pattern without escaping. If a role contains regex metacharacters (e.g., (, ), |, ., *), it could break the regex or enable ReDoS attacks.

🛡️ Proposed fix to escape role names
-        $roles = \implode('|', $this->authorization->getRoles());
+        $roles = \implode('|', \array_map(
+            fn($role) => \preg_quote($role, '/'),
+            $this->authorization->getRoles()
+        ));
         $filters['_permissions']['$in'] = [new Regex("{$forPermission}\\(\".*(?:{$roles}).*\"\\)", 'i')];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Hook/MongoPermissionFilter.php` around lines 26 - 27, The code
builds a Regex from role names without escaping them, risking regex injection;
update the logic that creates $roles (from $this->authorization->getRoles()) to
escape each role with a regex-escaping function (e.g., preg_quote) before
joining them, then use the escaped string when constructing the Regex used in
the $filters['_permissions']['$in'] assignment (the
Regex("{$forPermission}\\(\".*(?:{$roles}).*\"\\)", 'i') instantiation) so
special characters in role names are neutralized.
src/Database/Traits/Attributes.php-998-1015 (1)

998-1015: ⚠️ Potential issue | 🟠 Major

Rollback the previous default when metadata persistence fails.

If the adapter update succeeds and updateMetadata() then fails, the rollback model recreates the old column without its previous default. The physical schema can remain mutated while metadata rolls back.

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

In `@src/Database/Traits/Attributes.php` around lines 998 - 1015, The rollback
Attribute is missing the original default value so if the adapter update
succeeds but updateMetadata() fails the physical column loses its default;
modify the rollback model creation (the Attribute instance assigned to
$rollbackAttrModel) to include the previous default (e.g., $originalDefault) and
ensure whatever variable holds the prior default is captured and passed into the
Attribute constructor, then keep the rollback closure that calls
$this->adapter->updateAttribute($collection, $rollbackAttrModel, $originalKey)
unchanged so the adapter will restore the column including its original default
when metadata persistence fails.
src/Database/Traits/Relationships.php-893-902 (1)

893-902: ⚠️ Potential issue | 🟠 Major

Preserve the original side during rollback.

This recreation hard-codes RelationSide::Parent. If a child-side delete fails after the physical schema change, rollback will restore the relationship on the wrong side.

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

In `@src/Database/Traits/Relationships.php` around lines 893 - 902, The rollback
recreates the Relationship with a hard-coded RelationSide::Parent which can
restore the relation on the wrong side; change the code that builds
$recreateRelModel in the rollback to use the original relationship side (e.g.,
the existing side value from the original Relationship instance or the local
$side variable) instead of RelationSide::Parent so the recreated Relationship
preserves the original side; update the constructor call that sets side to use
that original side source (RelationSide value obtained from the original model)
when constructing $recreateRelModel.
src/Database/Traits/Attributes.php-281-284 (1)

281-284: ⚠️ Potential issue | 🟠 Major

Rollback should only delete attributes created in this batch.

$attributeDocuments also contains schema-only orphans that already existed before this call. If one new attribute is created and updateMetadata() fails, cleanupAttributes() will delete those pre-existing columns too.

Also applies to: 317-323

src/Database/Traits/Attributes.php-546-559 (1)

546-559: ⚠️ Potential issue | 🟠 Major

Invalidate both collection and metadata caches after these mutations.

createAttribute(), updateAttribute(), and deleteAttribute() purge cached collection state and the metadata document. updateAttributeMeta() does neither, and renameAttribute() still leaves the metadata document cache warm, so callers can read stale attribute definitions after a successful write.

Also applies to: 1284-1292

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

In `@src/Database/Traits/Attributes.php` around lines 546 - 559, After performing
attribute-mutating operations (specifically in updateAttributeMeta() and
renameAttribute()), ensure you clear both the collection cache and the metadata
document cache just like createAttribute()/updateAttribute()/deleteAttribute()
do: call the same cache invalidation logic used elsewhere after the
$this->updateMetadata(...) call (and before returning), and also ensure any
cache keys for the metadata document are purged so callers don't read stale
attribute definitions; reference updateAttributeMeta(), renameAttribute(), and
the existing $this->updateMetadata(...) / self::EVENT_ATTRIBUTE_UPDATE flow to
locate the correct spot to add the invalidation.
src/Database/Adapter.php-685-697 (1)

685-697: ⚠️ Potential issue | 🟠 Major

Don’t report unsupported relationship operations as success.

Traits\Relationships uses these return values to decide whether to update metadata. Returning true here lets an adapter without a real implementation claim success while leaving the physical schema unchanged.

🧱 Proposed fix
 public function createRelationship(Relationship $relationship): bool
 {
-    return true;
+    return false;
 }
 
 public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): bool
 {
-    return true;
+    return false;
 }
 
 public function deleteRelationship(Relationship $relationship): bool
 {
-    return true;
+    return false;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter.php` around lines 685 - 697, The Adapter currently
reports success for unsupported relationship operations; update the methods
createRelationship(Relationship $relationship), updateRelationship(Relationship
$relationship, ?string $newKey = null, ?string $newTwoWayKey = null) and
deleteRelationship(Relationship $relationship) to not claim success — return
false (or throw a clear UnsupportedOperationException) instead of returning true
so Traits\Relationships won't assume metadata was updated; ensure the change is
applied to those method bodies and keep the signatures unchanged.
src/Database/Traits/Relationships.php-518-579 (1)

518-579: ⚠️ Potential issue | 🟠 Major

Update both relationship metadata documents atomically.

These updateAttributeMeta() calls commit independently. If the first collection update succeeds and the second one fails, the catch only reverts the adapter rename; it never restores the already-persisted metadata change on the first collection.

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

In `@src/Database/Traits/Relationships.php` around lines 518 - 579, The metadata
updates via updateAttributeMeta on the primary collection, the related
collection (oldTwoWayKey), and the junction (when ManyToMany) must be performed
atomically; currently if one update fails the others remain persisted. Fix by
either wrapping all metadata updates and the adapter rename in a single DB
transaction (begin/commit/rollback) so any failure rolls back all changes, or if
transactions are not available, capture the existing attribute state for
collection->getId()/$id, relatedCollection->getId()/oldTwoWayKey, and junction
entries (if RelationType::ManyToMany) before applying updates and, inside the
catch, call updateAttributeMeta to restore those saved states (and ensure
purgeCachedCollection is similarly reverted). Update logic references:
updateAttributeMeta, getJunctionCollection, withRetries->purgeCachedCollection,
and the adapter->updateRelationship/Relationship model so both metadata and
adapter rename are consistent.
src/Database/Traits/Relationships.php-143-157 (1)

143-157: ⚠️ Potential issue | 🟠 Major

Check the related collection for twoWayKey collisions before creating the relationship.

The duplicate scan only inspects the source collection. checkAttribute() only enforces limits/width, so an existing attribute on the related collection with the same $twoWayKey is not rejected before partial relationship creation starts.

Also applies to: 191-192

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

In `@src/Database/Traits/Relationships.php` around lines 143 - 157, The
duplicate-check currently inspects only the source collection attributes (loop
over $attributes) and misses collisions on the related collection; update the
logic in the Relationships trait where you check for existing relationship
attributes (the foreach that compares ColumnType::Relationship->value, twoWayKey
and relatedCollection id) to also fetch and iterate the related collection's
attributes (use $relatedCollection->getAttribute('attributes', [])) and compare
their options['twoWayKey'] case-insensitively to $twoWayKey and their
relatedCollection id to the source collection id, throwing
DuplicateException('Related attribute already exists') on conflict; apply the
same symmetric check to the other similar block referenced by checkAttribute()
(the block around the other relationship-creation path) so both creation flows
validate twoWayKey collisions on the opposite collection before proceeding.
src/Database/Traits/Attributes.php-572-576 (1)

572-576: ⚠️ Potential issue | 🟠 Major

Keep the metadata-only helpers behind the same invariants as updateAttribute().

updateAttributeRequired() can mark an attribute required while leaving its old default in place, updateAttributeFilters() can remove mandatory filters like datetime, and updateAttributeDefault() never checks vector length against the configured dimension count. These helpers can persist attribute metadata that the full update path would reject.

Also applies to: 627-631, 644-653

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

In `@src/Database/Traits/Attributes.php` around lines 572 - 576, The three
metadata-only helpers (updateAttributeRequired, updateAttributeFilters,
updateAttributeDefault) bypass the same validation/invariants enforced by
updateAttribute and can persist invalid metadata; change each helper to call the
full update path or reuse its validation logic by delegating to updateAttribute
(or to the common validation function used by updateAttribute) instead of
directly mutating via updateAttributeMeta: ensure updateAttributeRequired
validates compatibility between required flag and the existing default,
updateAttributeFilters preserves/enforces mandatory filters (e.g., datetime) and
validates any removed filters against attribute type, and updateAttributeDefault
enforces vector length vs configured dimension count and any type-specific
constraints before persisting. Ensure you reference and reuse updateAttribute or
its shared validators so all invariants are consistently applied.
src/Database/Hook/PermissionWrite.php-131-136 (1)

131-136: ⚠️ Potential issue | 🟠 Major

Route permission reads/deletes through WriteContext::execute.

The insert path uses the context executor, but these branches call execute() directly and some of the delete helpers ignore the boolean result entirely. A failed permission mutation can therefore leave <collection>_perms out of sync with the document write.

Also applies to: 187-192, 208-215, 227-231, 262-266

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

In `@src/Database/Hook/PermissionWrite.php` around lines 131 - 136, The permission
delete branches in PermissionWrite (use of ($context->newBuilder),
$removeBuilder->delete(), and the subsequent ($context->executeResult) returning
$deleteStmt) call $deleteStmt->execute() directly and ignore the WriteContext
executor and result; change these to run deletes through the WriteContext
executor (use WriteContext::execute on the prepared statement returned by
($context->executeResult) with Database::EVENT_PERMISSIONS_DELETE), check the
boolean return value and propagate/handle failures (throw or abort the parent
write) so permission mutations cannot silently fail; apply the same fix pattern
to the other affected blocks that build/delete perms (the sections analogous to
lines 187-192, 208-215, 227-231, 262-266).
src/Database/Traits/Relationships.php-493-515 (1)

493-515: ⚠️ Potential issue | 🟠 Major

Don’t treat the unchanged source key as proof that the rename already happened.

When only $newTwoWayKey changes, $actualNewKey still points at the current source column. The orphan-recovery fallback will then mark $adapterUpdated = true as soon as it sees that existing column, even if the related/junction rename never happened.

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

In `@src/Database/Traits/Relationships.php` around lines 493 - 515, The recovery
logic in Relationships.php incorrectly treats finding $actualNewKey in schema as
proof the rename completed; instead, when catching the Throwable you must verify
the rename truly occurred by checking that the new column exists AND the
original source column was removed (or that the two-way/junction column change
also exists) — use getSchemaAttributes(), $this->adapter->filter($actualNewKey)
and also compare against the filtered original/source key (e.g., filteredOldKey)
and/or the filtered $newTwoWayKey to ensure the found column isn't simply the
unchanged source; only set $adapterUpdated = true if the new name exists AND the
old name does not (or the corresponding two-way column was updated), otherwise
rethrow the DatabaseException (preserving $e as previous).
src/Database/Traits/Documents.php-970-975 (1)

970-975: ⚠️ Potential issue | 🟠 Major

Use the hook's returned document in bulk updates.

updateDocument() reassigns afterDocumentUpdate(), but updateDocuments() ignores the returned value. Any relationship hook that rewrites the payload will be skipped on this path.

🔧 Proposed fix
                     $hook = $this->relationshipHook;
                     if ($hook?->isEnabled()) {
-                        $this->silent(fn () => $hook->afterDocumentUpdate($collection, $document, $new));
+                        $new = $this->silent(fn () => $hook->afterDocumentUpdate($collection, $document, $new));
                     }
 
                     $document = $new;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Traits/Documents.php` around lines 970 - 975, The bulk-update
path is ignoring the relationship hook's return value: in the block using
$this->relationshipHook and calling $hook->afterDocumentUpdate($collection,
$document, $new), capture and use the hook's returned document (replace $new
with the hook return when non-null) before assigning to $document so any payload
rewrites by afterDocumentUpdate are preserved; update the logic around
relationshipHook/isEnabled and the assignment to $document to use the hook's
return value (from afterDocumentUpdate) instead of discarding it.
src/Database/Traits/Documents.php-401-409 (1)

401-409: ⚠️ Potential issue | 🟠 Major

Run castingAfter() on single-document creates even when hooks are off.

This path only normalizes adapter output inside the relationship-hook branch. MariaDB::createDocument() returns the pre-cast document instance, so a single create can return DB-shaped values while createDocuments() returns normalized ones.

🔧 Proposed fix
         $hook = $this->relationshipHook;
         if ($hook !== null && !$hook->isInBatchPopulation() && $hook->isEnabled()) {
             $fetchDepth = $hook->getWriteStackCount();
             $documents = $this->silent(fn () => $hook->populateDocuments([$document], $collection, $fetchDepth));
-            $document = $this->adapter->castingAfter($collection, $documents[0]);
+            $document = $documents[0];
         }
 
+        $document = $this->adapter->castingAfter($collection, $document);
         $document = $this->casting($collection, $document);
         $document = $this->decode($collection, $document);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Traits/Documents.php` around lines 401 - 409, The current code
only runs $this->adapter->castingAfter(...) when $this->relationshipHook is
present and enabled, causing single-document creates to return DB-shaped values;
move or duplicate the adapter post-processing so castingAfter is applied for
single-document flows regardless of the relationship hook state: ensure that
after the potential hook population block you call
$this->adapter->castingAfter($collection, $document) (using the same normalized
$documents[0] result when hook ran) before running $this->casting(...) and
$this->decode(...), so both createDocument() and createDocuments() return
consistently normalized documents; use the existing symbols relationshipHook,
populateDocuments, castingAfter, casting, decode and silent to implement this
change.
src/Database/Traits/Documents.php-274-301 (1)

274-301: ⚠️ Potential issue | 🟠 Major

Preserve relationship roots when pruning selected fields.

$attributesToKeep only records the literal selector. A query like select(['author.name']) will therefore remove the populated author attribute itself, and aliased projections are dropped for the same reason. Please retain the root segment of dotted paths, plus any projection alias, before removing attributes.

Based on learnings: Repo utopia-php/database: Relationship selects are always evaluated in the main alias context (no per-collection aliasing). In Utopia\Database\Database::applySelectFiltersToDocuments, do not rely on Query::getAlias() for relationships; instead, preserve the root of dotted paths and any projection alias (Query::getAs()) when filtering attributes.

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

In `@src/Database/Traits/Documents.php` around lines 274 - 301, The pruning logic
in applySelectFiltersToDocuments builds $attributesToKeep from selectQueries by
using literal selectors only, which drops relationship root attributes and
aliased projections (e.g., select(['author.name']) or projections with
Query::getAs()). Update the loop that populates $attributesToKeep (the foreach
over $selectQueries and getValues()) to also: for each selector string preserve
its root segment (the part before the first dot) and, if Query::getAs() is
present for that query, preserve the alias name as well; do not rely on
Query::getAlias() for relationship selects. Ensure these root keys and
projection aliases are added to $attributesToKeep before the
wildcard/internal-key checks so removeAttribute only strips truly unselected
fields.
src/Database/Traits/Documents.php-1174-1215 (1)

1174-1215: ⚠️ Potential issue | 🟠 Major

Don't drop brand-new empty documents as a no-op.

When $old is empty and the incoming document has no user fields/operators, $hasChanges stays false and the item is removed from the batch. upsertDocument() then falls back to getDocument() and returns an empty document instead of creating a valid empty record.

🔧 Proposed fix
-            if (!$hasChanges) {
+            if (!$hasChanges && !$old->isEmpty()) {
                 // If not updating a single attribute and the document is the same as the old one, skip it
                 unset($documents[$key]);
                 continue;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Traits/Documents.php` around lines 1174 - 1215, The current
change-detection logic in the Documents trait can wrongly treat brand-new empty
incoming docs as unchanged and drop them from $documents[$key], because when
$old is empty and regularUpdatesUserOnly and $operators are empty $hasChanges
remains false; update the logic in the block that computes $hasChanges (the
checks that reference $operators, $attribute, $skipPermissionsUpdate, $old,
regularUpdatesUserOnly, self::INTERNAL_ATTRIBUTES) to explicitly mark a document
as changed when $old is null/empty (new record) even if no user fields/operators
are present so upsertDocument() will create the empty record instead of falling
back to getDocument(); ensure this new case still respects internal attribute
filtering and the subsequent unset($documents[$key]) skip only applies to truly
identical existing documents.
src/Database/Adapter/MariaDB.php-603-609 (1)

603-609: ⚠️ Potential issue | 🟠 Major

Use side-aware junction naming when renaming many-to-many columns.

deleteRelationship() computes the junction table name differently for parent vs child sides, but updateRelationship() always uses <collection>_<related>. Child-side renames will target the wrong junction table.

🔧 Proposed fix
-                $junctionName = '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence();
+                $junctionName = $side === RelationSide::Parent
+                    ? '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()
+                    : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence();
🤖 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 603 - 609,
updateRelationship() is using a fixed junction name ('_' .
$collection->getSequence() . '_' . $relatedCollection->getSequence()) which
ignores the relationship side and causes child-side renames to target the wrong
junction table; change the junction name computation in updateRelationship() to
mirror the logic used in deleteRelationship() (i.e., compute $junctionName based
on the $side or whichever variable deleteRelationship() uses to decide order of
$collection->getSequence() and $relatedCollection->getSequence()), then use that
side-aware $junctionName when calling $renameCol for $key/$newKey and
$twoWay/$newTwoWayKey so renames operate on the correct many-to-many table.
src/Database/Adapter/MariaDB.php-1211-1228 (1)

1211-1228: ⚠️ Potential issue | 🟠 Major

Add handlers for TYPE_COVERS, TYPE_NOT_COVERS, TYPE_SPATIAL_EQUALS, and TYPE_NOT_SPATIAL_EQUALS in the spatial query match statement.

The validator in src/Database/Validator/Queries.php allows these four new spatial query types, but the match statement in src/Database/Adapter/MariaDB.php (lines 1211-1228) does not handle them. These queries will throw Unknown spatial query method at runtime when executed. The same issue exists in src/Database/Adapter/Postgres.php.

🤖 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 1211 - 1228, The match in
MariaDB.php handling spatial query methods misses Query::TYPE_COVERS,
Query::TYPE_NOT_COVERS, Query::TYPE_SPATIAL_EQUALS and
Query::TYPE_NOT_SPATIAL_EQUALS; add match arms that map TYPE_COVERS to
"ST_Covers({$alias}.{$attribute}, {$geom})", TYPE_NOT_COVERS to "NOT
ST_Covers(...)", TYPE_SPATIAL_EQUALS to "ST_SpatialEquals({$alias}.{$attribute},
{$geom})" and TYPE_NOT_SPATIAL_EQUALS to "NOT ST_SpatialEquals(...)" (use the
same pattern/templating as the existing arms such as Query::TYPE_CONTAINS and
Query::TYPE_EQUAL), and apply the identical changes to the corresponding match
in Postgres.php so both adapters support the four new Query types.
src/Database/Adapter/Mongo.php-1064-1069 (1)

1064-1069: ⚠️ Potential issue | 🟠 Major

Resolve partial-index types by attribute name, not loop offset.

Line 1165 builds $indexAttributeTypes as [$attrName => $type], but Line 1067 reads $indexAttributeTypes[$i]. That falls back to string for numeric/date fields and can exclude the real field type from the partial index entirely.

Suggested fix
         if (in_array($type, [IndexType::Unique, IndexType::Key])) {
             $partialFilter = [];
             foreach ($attributes as $i => $attr) {
-                $attrType = $indexAttributeTypes[$i] ?? ColumnType::String->value; // Default to string if type not provided
+                $sourceAttribute = $index->attributes[$i] ?? $attr;
+                $attrType = $indexAttributeTypes[$sourceAttribute] ?? ColumnType::String->value;
                 $attrType = $this->getMongoTypeCode($attrType);
                 $partialFilter[$attr] = ['$exists' => true, '$type' => $attrType];
             }
🤖 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 1064 - 1069, The partial index
builder in Mongo.php is using the loop index to look up attribute types but
$indexAttributeTypes is keyed by attribute name; update the lookup in the loop
that constructs $partialFilter (in the block handling IndexType::Unique and
IndexType::Key) to use the attribute name ($attr) as the key instead of $i, e.g.
fetch the type with $indexAttributeTypes[$attr] ?? ColumnType::String->value and
then pass that into $this->getMongoTypeCode so the correct field type is used
for the partial index filter.
🟡 Minor comments (12)
docker-compose.yml-6-7 (1)

6-7: ⚠️ Potential issue | 🟡 Minor

Document local development setup requirement for sibling query repository.

The volume mount ../query/src:/usr/src/code/vendor/utopia-php/query/src and build context .. assume a specific local directory structure where the query repository exists as a sibling. This dependency is not documented in CONTRIBUTING.md. While the volume mount failure is non-fatal in CI (the container starts regardless), local development with docker compose up -d --build will fail without the sibling query repository, making setup unclear for new contributors.

Also applies to: 21-21

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

In `@docker-compose.yml` around lines 6 - 7, The docker-compose configuration uses
a sibling repo via the volume mount
'../query/src:/usr/src/code/vendor/utopia-php/query/src' and a build context
'..' (with dockerfile database/Dockerfile), which must be documented; update
CONTRIBUTING.md to state that the `query` repository must exist as a sibling (or
provide steps to clone it) before running `docker compose up -d --build`, note
the mount is optional for CI and point to the alternative (remove/disable the
volume or use a docker-compose.override.yml) for contributors who cannot have
the sibling repo, and mirror this documentation for the other occurrence
referenced (line 21) so local dev setup is explicit.
README.md-636-638 (1)

636-638: ⚠️ Potential issue | 🟡 Minor

Add import statement for ForeignKeyAction in the example.

The example shows ForeignKeyAction::Cascade->value but doesn't include the necessary use statement. This could confuse developers trying to use the code.

📝 Suggested addition before line 636
use Utopia\Database\ForeignKeyAction;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 636 - 638, Add a use/import for the ForeignKeyAction
enum in the example so the symbols like ForeignKeyAction::Cascade->value,
ForeignKeyAction::SetNull->value, and ForeignKeyAction::Restrict->value resolve;
specifically, add the statement to import the class (e.g., use
Utopia\Database\ForeignKeyAction;) near the top of the snippet where the example
code begins so ForeignKeyAction is available in that example context.
README.md-758-776 (1)

758-776: ⚠️ Potential issue | 🟡 Minor

Add import statement for SetType in the example.

Similar to ForeignKeyAction, the SetType enum usage examples should include the import statement for clarity.

📝 Suggested addition before line 758
use Utopia\Database\SetType;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 758 - 776, Add the missing import for the SetType
enum used in the README examples by inserting the statement importing SetType
(e.g., use Utopia\Database\SetType;) near the other example imports so the
usages of SetType::Assign / SetType::Append / SetType::Prepend in the
setAttribute examples resolve; ensure it appears alongside existing imports like
Permission and Role so the sample snippet is self-contained and clear.
src/Database/Relationship.php-21-31 (1)

21-31: ⚠️ Potential issue | 🟡 Minor

toDocument() omits collection and key properties.

The toDocument() method doesn't serialize $this->collection or $this->key, but fromDocument() accepts both as inputs (collection as parameter, key from attribute). This asymmetry may cause data loss during round-trip serialization if these fields need to be preserved.

If this is intentional (because these fields come from external context), consider documenting this behavior.

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

In `@src/Database/Relationship.php` around lines 21 - 31, The toDocument() method
on Relationship currently omits $this->collection and $this->key while
fromDocument() (and the static fromDocument method) accept collection and key,
causing asymmetrical serialization; modify Relationship::toDocument() to include
'collection' => $this->collection and 'key' => $this->key in the returned
Document (or, if omission is intentional, add a clear docblock on
Relationship::toDocument() and fromDocument() explaining that collection and key
are provided externally and therefore not persisted) so round-trip serialization
is consistent.
src/Database/Hook/RelationshipHandler.php-243-243 (1)

243-243: ⚠️ Potential issue | 🟡 Minor

Unused loop variable $index.

The $index variable from the foreach loop is declared but never used. This was correctly flagged by static analysis.

🔧 Proposed fix
-        foreach ($relationships as $index => $relationship) {
+        foreach ($relationships as $relationship) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Hook/RelationshipHandler.php` at line 243, The foreach in
RelationshipHandler.php declares an unused loop variable $index; change the loop
signature from foreach ($relationships as $index => $relationship) to foreach
($relationships as $relationship) (or use foreach ($relationships as $_ =>
$relationship) if you prefer an explicit unused key) so the unused $index is
removed, keeping the loop body using $relationship unchanged.
src/Database/Hook/RelationshipHandler.php-1803-1814 (1)

1803-1814: ⚠️ Potential issue | 🟡 Minor

Same shadowing issue: $document in deleteCascade.

The loop variable $document shadows the method parameter, same issue as in deleteSetNull. The loop iterates over junction documents while the original parameter is the document being deleted.

🔧 Rename loop variable
-                foreach ($junctions as $document) {
+                foreach ($junctions as $junctionDoc) {
                     if ($side === RelationSide::Parent->value) {
                         $this->db->deleteDocument(
                             $relatedCollection->getId(),
-                            $document->getAttribute($key)
+                            $junctionDoc->getAttribute($key)
                         );
                     }
                     $this->db->deleteDocument(
                         $junction,
-                        $document->getId()
+                        $junctionDoc->getId()
                     );
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Hook/RelationshipHandler.php` around lines 1803 - 1814, In
deleteCascade, the foreach loop uses $document which shadows the method
parameter $document; rename the loop variable (e.g., to $junctionDocument or
$junctionDoc) wherever it's used within the loop so the parameter $document
remains the original document being deleted, and update the two deleteDocument
calls to reference the renamed loop variable
($junctionDocument->getAttribute($key) and $junctionDocument->getId()) while
leaving the method parameter untouched.
src/Database/Attribute.php-76-92 (1)

76-92: ⚠️ Potential issue | 🟡 Minor

fromArray() omits status and options fields.

Unlike fromDocument(), the fromArray() method doesn't extract status and options from the input array. This asymmetry could cause data loss when attributes with these fields are serialized via toDocument() and then reconstructed via fromArray().

🔧 Proposed fix to include status and options
     public static function fromArray(array $data): self
     {
         $type = $data['type'] ?? 'string';
 
         return new self(
             key: $data['$id'] ?? $data['key'] ?? '',
             type: $type instanceof ColumnType ? $type : ColumnType::from($type),
             size: $data['size'] ?? 0,
             required: $data['required'] ?? false,
             default: $data['default'] ?? null,
             signed: $data['signed'] ?? true,
             array: $data['array'] ?? false,
             format: $data['format'] ?? null,
             formatOptions: $data['formatOptions'] ?? [],
             filters: $data['filters'] ?? [],
+            status: $data['status'] ?? null,
+            options: $data['options'] ?? null,
         );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Attribute.php` around lines 76 - 92, fromArray() currently
ignores the 'status' and 'options' keys so reconstructing an Attribute via
Attribute::fromArray loses data present in toDocument()/fromDocument(); update
Attribute::fromArray to read 'status' and 'options' from the input array (e.g.
$data['status'] ?? <sensible default> and $data['options'] ?? <default empty
array>) and pass them into the Attribute constructor (matching how
fromDocument() does), ensuring defaults mirror fromDocument/toDocument behavior.
src/Database/Hook/RelationshipHandler.php-272-283 (1)

272-283: ⚠️ Potential issue | 🟡 Minor

Loose equality comparison may cause unexpected behavior.

Using == for comparing $oldValue and $value (Line 272) can lead to unexpected type coercion, especially when comparing Documents, arrays, or mixed types. This could cause relationship updates to be incorrectly skipped.

🔧 Consider strict comparison or dedicated comparison logic
-            if ($oldValue == $value) {
+            if ($this->areRelationshipValuesEqual($oldValue, $value)) {

Alternatively, if loose comparison is intentional for this specific use case, add a comment explaining why.

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

In `@src/Database/Hook/RelationshipHandler.php` around lines 272 - 283, The loose
comparison using $oldValue == $value in RelationshipHandler (around the block
handling RelationType and RelationSide) can produce incorrect skips; change this
to strict comparison or explicit comparison logic: use === for scalar checks, if
both values are Document instances compare their IDs (e.g., $oldValue->getId()
=== $value->getId()), and for arrays use a deep equality check (or
serialize/sort then compare) before deciding to set or remove attributes via
$document->setAttribute/$document->removeAttribute; if loose comparison was
intentional, replace the == with a clear comment explaining why and what cases
rely on coercion.
src/Database/Hook/RelationshipHandler.php-1727-1732 (1)

1727-1732: ⚠️ Potential issue | 🟡 Minor

Parameter shadowing: $document reused in loop.

The foreach loop variable $document shadows the method parameter $document (from Line 1640), which could cause confusion or unintended behavior if the original document is needed after the loop.

🔧 Rename loop variable
-                foreach ($junctions as $document) {
-                    $this->db->skipRelationships(fn () => $this->db->deleteDocument(
-                        $junction,
-                        $document->getId()
-                    ));
+                foreach ($junctions as $junctionDoc) {
+                    $this->db->skipRelationships(fn () => $this->db->deleteDocument(
+                        $junction,
+                        $junctionDoc->getId()
+                    ));
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Hook/RelationshipHandler.php` around lines 1727 - 1732, The
foreach loop reuses the variable name $document which shadows the method
parameter $document; rename the loop variable (e.g., $junctionDocument or
$junctionItem) and update its usage inside the loop so the method parameter
remains available unchanged, keeping the existing calls to
$this->db->skipRelationships(fn () => $this->db->deleteDocument($junction,
<loop-var>->getId())); ensure only the loop variable name is changed (no other
logic altered).
src/Database/Traits/Indexes.php-359-366 (1)

359-366: ⚠️ Potential issue | 🟡 Minor

Fix rollback index TTL default: should be 0, not 1, to match TTL semantics elsewhere.

The rollback index uses getAttribute('ttl', 1) as default, but throughout the codebase TTL semantics treat 0 (or missing attribute) as "no TTL configured". In Documents.php:247, the code explicitly checks if ($ttlSeconds <= 0 || !$ttlAttr) to determine whether to skip TTL processing. Additionally, the Validator/Index.php:810 uses getAttribute('ttl', 0) as default. Using 1 as the default means if the original index had no TTL attribute, the rollback would incorrectly create an index with a 1-second TTL, causing documents to expire prematurely. Change the default to 0 to preserve the "no TTL" state when the attribute is missing.

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

In `@src/Database/Traits/Indexes.php` around lines 359 - 366, The rollback index
creation uses getAttribute('ttl', 1) which incorrectly sets a 1s TTL when the
attribute is missing; change the default to 0 so $rollbackIndex is created with
no TTL when absent by updating the call on $indexDeleted->getAttribute('ttl', 1)
to use 0 (the code building the Index instance with IndexType::from(...) and
attributes/lengths/orders should remain the same) to match Validator/Index.php
and Documents.php TTL semantics.
src/Database/Adapter/SQLite.php-43-70 (1)

43-70: ⚠️ Potential issue | 🟡 Minor

SQLite advertises it does not support Capability::Upserts but still implements upsert functionality.

Line 62 removes Capability::Upserts from the advertised capabilities, yet SQLite inherits upsertDocuments() from SQL and overrides executeUpsertBatch() to handle SQLite's ON CONFLICT syntax. This creates an inconsistency: the capability advertisement does not match the runtime behavior. While this doesn't currently cause dead code (no code gate upsert calls on supports(Capability::Upserts)), the mismatch violates the contract that removed capabilities should not be implemented. Either re-add Capability::Upserts to SQLite's capabilities, or remove the executeUpsertBatch() override if SQLite should not support upserts.

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

In `@src/Database/Adapter/SQLite.php` around lines 43 - 70, The SQLite adapter
currently removes Capability::Upserts in capabilities() while still implementing
upsertDocuments() and overriding executeUpsertBatch(), causing a contract
mismatch; fix by either (A) re-adding support: remove Capability::Upserts from
the $remove array in capabilities() so parent::capabilities() reports Upserts,
or (B) disable runtime support: delete or revert the
SQLite::executeUpsertBatch() override (and any SQLite-specific upsert helpers)
so the adapter no longer implements upsert behavior — choose one approach and
make the changes consistently (references: capabilities(), Capability::Upserts,
upsertDocuments(), executeUpsertBatch(), parent::capabilities()).
src/Database/Adapter/Mongo.php-1081-1081 (1)

1081-1081: ⚠️ Potential issue | 🟡 Minor

Remove the ->value accessor to fix the unreachable readiness loop.

At line 1081, $type is an IndexType enum case (assigned from $index->type at line 44), but the comparison $type === IndexType::Unique->value compares an enum case object to its backing scalar value. This will never match in PHP—enum cases and their backing values are distinct types. As a result, the readiness loop is unreachable and the code never waits for unique indexes to be fully built.

The fix is to compare the enum case directly:

Fix
-            if ($type === IndexType::Unique->value) {
+            if ($type === IndexType::Unique) {

This is consistent with all other enum comparisons in the same function (lines 1020, 1042, 1050, 1055, 1060), which use enum cases without the ->value accessor.

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

In `@src/Database/Adapter/Mongo.php` at line 1081, The readiness loop is
unreachable because it compares the enum case object $type to the backing scalar
of IndexType::Unique (using ->value); locate the comparison of $type and
IndexType::Unique in the readiness/wait loop and remove the ->value accessor so
the code compares the enum case directly (i.e., use IndexType::Unique) to match
how other enum checks in this function handle $type.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3dfcd406-2dfe-4455-a2f0-baf7fa17d195

📥 Commits

Reviewing files that changed from the base of the PR and between b0a1faf and 4072192.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (128)
  • .github/workflows/tests.yml
  • Dockerfile
  • README.md
  • bin/tasks/relationships.php
  • composer.json
  • docker-compose.yml
  • src/Database/Adapter.php
  • src/Database/Adapter/Feature/Attributes.php
  • src/Database/Adapter/Feature/Collections.php
  • src/Database/Adapter/Feature/ConnectionId.php
  • src/Database/Adapter/Feature/Databases.php
  • src/Database/Adapter/Feature/Documents.php
  • src/Database/Adapter/Feature/Indexes.php
  • src/Database/Adapter/Feature/InternalCasting.php
  • src/Database/Adapter/Feature/Relationships.php
  • src/Database/Adapter/Feature/SchemaAttributes.php
  • src/Database/Adapter/Feature/Spatial.php
  • src/Database/Adapter/Feature/Timeouts.php
  • src/Database/Adapter/Feature/Transactions.php
  • src/Database/Adapter/Feature/UTCCasting.php
  • src/Database/Adapter/Feature/Upserts.php
  • src/Database/Adapter/MariaDB.php
  • src/Database/Adapter/Mongo.php
  • src/Database/Adapter/Mongo/RetryClient.php
  • src/Database/Adapter/MySQL.php
  • src/Database/Adapter/Pool.php
  • src/Database/Adapter/Postgres.php
  • src/Database/Adapter/SQL.php
  • src/Database/Adapter/SQLite.php
  • src/Database/Attribute.php
  • src/Database/Capability.php
  • src/Database/CursorDirection.php
  • src/Database/Database.php
  • src/Database/Document.php
  • src/Database/Helpers/Permission.php
  • src/Database/Hook/MongoPermissionFilter.php
  • src/Database/Hook/MongoTenantFilter.php
  • src/Database/Hook/PermissionFilter.php
  • src/Database/Hook/PermissionWrite.php
  • src/Database/Hook/Read.php
  • src/Database/Hook/Relationship.php
  • src/Database/Hook/RelationshipHandler.php
  • src/Database/Hook/TenantFilter.php
  • src/Database/Hook/TenantWrite.php
  • src/Database/Hook/Write.php
  • src/Database/Hook/WriteContext.php
  • src/Database/Index.php
  • src/Database/Mirror.php
  • src/Database/Operator.php
  • src/Database/OperatorType.php
  • src/Database/OrderDirection.php
  • src/Database/PermissionType.php
  • src/Database/Query.php
  • src/Database/RelationSide.php
  • src/Database/RelationType.php
  • src/Database/Relationship.php
  • src/Database/SetType.php
  • src/Database/Traits/Attributes.php
  • src/Database/Traits/Collections.php
  • src/Database/Traits/Databases.php
  • src/Database/Traits/Documents.php
  • src/Database/Traits/Indexes.php
  • src/Database/Traits/Relationships.php
  • src/Database/Traits/Transactions.php
  • src/Database/Validator/Attribute.php
  • src/Database/Validator/Datetime.php
  • src/Database/Validator/Index.php
  • src/Database/Validator/IndexedQueries.php
  • src/Database/Validator/Operator.php
  • src/Database/Validator/Permissions.php
  • src/Database/Validator/Queries.php
  • src/Database/Validator/Queries/Document.php
  • src/Database/Validator/Queries/Documents.php
  • src/Database/Validator/Query/Filter.php
  • src/Database/Validator/Query/Limit.php
  • src/Database/Validator/Query/Offset.php
  • src/Database/Validator/Sequence.php
  • src/Database/Validator/Spatial.php
  • src/Database/Validator/Structure.php
  • tests/e2e/Adapter/Base.php
  • tests/e2e/Adapter/MariaDBTest.php
  • tests/e2e/Adapter/MirrorTest.php
  • tests/e2e/Adapter/MongoDBTest.php
  • tests/e2e/Adapter/MySQLTest.php
  • tests/e2e/Adapter/PoolTest.php
  • tests/e2e/Adapter/PostgresTest.php
  • tests/e2e/Adapter/SQLiteTest.php
  • tests/e2e/Adapter/Schemaless/MongoDBTest.php
  • tests/e2e/Adapter/Scopes/AttributeTests.php
  • tests/e2e/Adapter/Scopes/CollectionTests.php
  • tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php
  • tests/e2e/Adapter/Scopes/DocumentTests.php
  • tests/e2e/Adapter/Scopes/GeneralTests.php
  • tests/e2e/Adapter/Scopes/IndexTests.php
  • tests/e2e/Adapter/Scopes/ObjectAttributeTests.php
  • tests/e2e/Adapter/Scopes/OperatorTests.php
  • tests/e2e/Adapter/Scopes/PermissionTests.php
  • tests/e2e/Adapter/Scopes/RelationshipTests.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php
  • tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
  • tests/e2e/Adapter/Scopes/SpatialTests.php
  • tests/e2e/Adapter/Scopes/VectorTests.php
  • tests/e2e/Adapter/SharedTables/MariaDBTest.php
  • tests/e2e/Adapter/SharedTables/MongoDBTest.php
  • tests/e2e/Adapter/SharedTables/MySQLTest.php
  • tests/e2e/Adapter/SharedTables/PostgresTest.php
  • tests/e2e/Adapter/SharedTables/SQLiteTest.php
  • tests/unit/DocumentTest.php
  • tests/unit/OperatorTest.php
  • tests/unit/PermissionTest.php
  • tests/unit/QueryTest.php
  • tests/unit/Validator/AttributeTest.php
  • tests/unit/Validator/AuthorizationTest.php
  • tests/unit/Validator/DocumentQueriesTest.php
  • tests/unit/Validator/DocumentsQueriesTest.php
  • tests/unit/Validator/IndexTest.php
  • tests/unit/Validator/IndexedQueriesTest.php
  • tests/unit/Validator/OperatorTest.php
  • tests/unit/Validator/QueriesTest.php
  • tests/unit/Validator/Query/FilterTest.php
  • tests/unit/Validator/Query/OrderTest.php
  • tests/unit/Validator/Query/SelectTest.php
  • tests/unit/Validator/QueryTest.php
  • tests/unit/Validator/SpatialTest.php
  • tests/unit/Validator/StructureTest.php

Comment thread src/Database/Adapter/Mongo.php Outdated
Comment thread src/Database/Index.php
Comment thread src/Database/Traits/Documents.php

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/Database/Adapter/Pool.php (1)

80-88: Use the short class name for consistency.

Line 80 uses the fully qualified class name \Utopia\Database\Capability even though Capability is already imported at line 7. Use the short name for consistency with the rest of the file.

Suggested fix
-    public function supports(\Utopia\Database\Capability $feature): bool
+    public function supports(Capability $feature): bool
     {
         return $this->delegate(__FUNCTION__, \func_get_args());
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Database/Adapter/Pool.php` around lines 80 - 88, Change the
fully-qualified parameter type in the supports method to the short imported
class name: replace "\Utopia\Database\Capability $feature" with "Capability
$feature" in the supports method signature so it matches the rest of the file
and uses the imported Capability symbol; ensure the method still calls
$this->delegate(__FUNCTION__, func_get_args()) unchanged.
src/Database/Adapter/SQLite.php (1)

247-248: Remove the duplicated _perms index creation.

These same two indexes are already created on Lines 247-248. Repeating them here just adds extra catalog probes and will become observable if index-create hooks/events are added later.

♻️ Proposed cleanup
-            $this->createIndex("{$id}_perms", new Index(key: '_index_1', type: IndexType::Unique, attributes: ['_document', '_type', '_permission']));
-            $this->createIndex("{$id}_perms", new Index(key: '_index_2', type: IndexType::Key, attributes: ['_permission', '_type']));

Also applies to: 265-266

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

In `@src/Database/Adapter/SQLite.php` around lines 247 - 248, The code calls
$this->createIndex twice for the same "{$id}_perms" table with identical
Index(...) definitions (using Index(key: '_index_1', type: IndexType::Unique,
attributes: ['_document','_type','_permission']) and Index(key: '_index_2',
type: IndexType::Key, attributes: ['_permission','_type'])), causing duplicate
index creation; remove the duplicated createIndex calls (the repeated
"{$id}_perms" invocations) so each index is created only once, leaving a single
call to createIndex for each Index definition (references: createIndex, Index,
IndexType).
🤖 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/Adapter/SQLite.php`:
- Around line 45-65: The capabilities() method currently removes
Capability::Upserts from the $remove list which causes
supports(Capability::Upserts) to return false even though this class implements
SQLite-specific ON CONFLICT expressions and executeUpsertBatch(); fix by
removing Capability::Upserts from the $remove array (so Upserts is reported as
supported) and ensure any related logic in executeUpsertBatch() and
conflict-expression generation remains intact to be exercised when
supports(Capability::Upserts) is checked.

In `@src/Database/Mirror.php`:
- Around line 1074-1086: The setRelationshipHook method in Mirror currently
discards the caller's RelationshipHook by always installing a new
RelationshipHandler on the source/destination; instead, forward the provided
$hook (or null) directly to the underlying databases. In the
Mirror::setRelationshipHook(?RelationshipHook $hook) implementation replace the
calls that pass new RelationshipHandler($this->source)/new
RelationshipHandler($this->destination) with the original $hook (or null) so the
caller's custom RelationshipHook is preserved when calling
$this->source->setRelationshipHook(...) and
$this->destination->setRelationshipHook(...).
- Around line 321-333: The code calls Attribute::fromDocument($document) after
running filters in $this->writeFilters, but Filter::beforeCreateAttribute() can
return null to indicate the attribute should be skipped; update the blocks that
call beforeCreateAttribute (e.g., the loop using $this->writeFilters and the
code that assigns $filteredAttribute) to check if $document is null after the
loop and, if so, skip further processing (do not call Attribute::fromDocument) —
apply the same null-check/skipping logic to the other symmetric block that
processes attributes (the second occurrence mentioned around the attribute
handling).

---

Nitpick comments:
In `@src/Database/Adapter/Pool.php`:
- Around line 80-88: Change the fully-qualified parameter type in the supports
method to the short imported class name: replace "\Utopia\Database\Capability
$feature" with "Capability $feature" in the supports method signature so it
matches the rest of the file and uses the imported Capability symbol; ensure the
method still calls $this->delegate(__FUNCTION__, func_get_args()) unchanged.

In `@src/Database/Adapter/SQLite.php`:
- Around line 247-248: The code calls $this->createIndex twice for the same
"{$id}_perms" table with identical Index(...) definitions (using Index(key:
'_index_1', type: IndexType::Unique, attributes:
['_document','_type','_permission']) and Index(key: '_index_2', type:
IndexType::Key, attributes: ['_permission','_type'])), causing duplicate index
creation; remove the duplicated createIndex calls (the repeated "{$id}_perms"
invocations) so each index is created only once, leaving a single call to
createIndex for each Index definition (references: createIndex, Index,
IndexType).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6220a957-b838-4386-98ab-e1a755c06239

📥 Commits

Reviewing files that changed from the base of the PR and between 4072192 and 2546230.

📒 Files selected for processing (7)
  • .gitignore
  • src/Database/Adapter/Pool.php
  • src/Database/Adapter/SQLite.php
  • src/Database/Database.php
  • src/Database/Mirror.php
  • tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php
  • tests/e2e/Adapter/Scopes/SchemalessTests.php
✅ Files skipped from review due to trivial changes (1)
  • .gitignore

Comment thread src/Database/Adapter/SQLite.php
Comment thread src/Database/Mirror.php Outdated
Comment thread src/Database/Mirror.php Outdated
@abnegate abnegate closed this Mar 12, 2026
@abnegate abnegate reopened this Mar 12, 2026
@abnegate

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Mar 31, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@abnegate abnegate closed this Mar 31, 2026
@abnegate abnegate reopened this Mar 31, 2026
@abnegate
abnegate force-pushed the feat-query-lib branch 2 times, most recently from d38a772 to fa461e5 Compare April 1, 2026 00:16

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

Reviewed this PR for CRITICAL/HIGH/MEDIUM findings (bugs and CLAUDE.md compliance).

1 HIGH finding — a permission-check regex regression that was silently reintroduced during the query-lib refactor. Details inline.

Other items considered and cleared:

  • Import ordering in Hook/Relationships.php and ORM/EntityMapper.php — conforms to Pint/PSR-12 case-insensitive ordering.
  • Missing readonly on #[Attribute] mapping classes — AGENTS.md phrases this as a conditional preference ("readonly properties for immutable data"), not a hard rule; not a clear violation.
  • Bug scan across SQL.php, adapter files, traits, hooks, validators, and ORM/ — no high-confidence bugs found in the new code (several initially-suspicious patterns turned out to be pre-existing on main or safely handled).

Comment thread src/Database/Hook/MongoPermissionFilter.php Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Claude pushed fixes from: improvement

15e6aa7...c2b792e

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

Reviewed this PR for CRITICAL/HIGH/MEDIUM findings (bugs and CLAUDE.md compliance). 1 HIGH finding — a cross-tenant data-leak regression in the newly extracted TenantFilter hook. Details inline.

Other items considered and cleared:

  • Duplicate createIndex calls in SQLite.php:267-268 — no-op because createIndex has an explicit IF NOT EXISTS short-circuit (lines 438-449). Redundant but not a bug; CodeRabbit already flagged as a nit.
  • Mirror::addHook constructing fresh Relationships($this->source/destination) — the new hook is bound to the correct target database; not a clear regression.
  • Missing readonly on new Attribute/Index/Collection/Relationship value objects — AGENTS.md phrases this as a conditional preference ("readonly properties for immutable data"), not an enforced rule; the prior review cycle on this PR already dismissed the analogous ORM case.
  • Snake_case test_* method naming across ~30 new test files — widespread deliberate convention change for the refactor, not a discrete bug.
  • Bug scan across new Hook/**, adapters, traits, and validators — no high-confidence regressions found beyond the one flagged below.

Comment thread src/Database/Hook/TenantFilter.php Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Claude pushed fixes from: improvement

b6198a6...997093a

@claude

claude Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

CI healing — run 24884705670

Root-caused two undefined-constant errors introduced by the recent merge from main into feat-query-lib:

1. Unit Tests (3 errors) — Utopia\Database\Query::LOGICAL_TYPES

Query::shape() at src/Database/Query.php:161 references self::LOGICAL_TYPES, but the constant was never declared on the Query class (nor inherited from the base Utopia\Query\Query). This made testShape, testFingerprint, and testFingerprintNestedLogicalQueries all error on the first call into shape().

Fix:

  • Added private const LOGICAL_TYPES = [Method::And, Method::Or, Method::ElemMatch] — matches the logical methods already listed in toArray().
  • Added public const TYPE_ELEM_MATCH = 'elemMatch' (the fingerprint/shape tests construct new Query(Query::TYPE_ELEM_MATCH, ...) and it was also undefined).
  • Fixed $node->method . ':' . ... concatenations — $node->method is a Method enum, not a string; changed to $node->method->value.

2. Adapter Tests / Mirror (1 error) — Utopia\Database\Database::VAR_STRING

tests/e2e/Adapter/MirrorTest.php:330 still used the removed Database::VAR_STRING constant inside a raw new Document([...]) attribute definition. Every other test in this file already uses the new Attribute(key:, type: ColumnType::String, ...) form.

Fix: Converted that one usage to the Attribute(...) form to match the rest of the file.

Verification

  • vendor/bin/paratest --functional --processes 4 /usr/src/code/tests/unit → 1220 tests, 0 errors (was 3 errors).
  • vendor/bin/phpunit --filter 'QueryTest::(testShape|testFingerprint|testFingerprintNestedLogicalQueries)' → 4/4 pass.
  • PHPStan clean on the changed file.

Note on the Mirror test

With the VAR_STRING constant fixed, testCreateDocumentsSkipDuplicatesBackfillsDestination now runs past the attribute definition and exposes a separate, pre-existing issue on this branch: Database::skipDuplicates() does not propagate to the adapter, and SQL::createDocuments() calls $builder->insert() unconditionally instead of $builder->insertOrIgnore() when the flag is set. That's out of scope for a CI-healing fix (the reported error was VAR_STRING, which is resolved), but it's a real skipDuplicates feature gap that will surface once the adapter runs this test end-to-end. Left for a follow-up commit.

@github-actions

Copy link
Copy Markdown
Contributor

Claude pushed fixes from: healing

997093a...ca4e2a7

@claude

claude Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

CI heal (run 24886550663)

Failure: Tests\E2E\Adapter\MirrorTest::testCreateDocumentsSkipDuplicatesBackfillsDestination

Utopia\Database\Exception\Duplicate: Document already exists
Caused by: PDOException SQLSTATE[23000]: 1062 Duplicate entry 'dup' for key '_uid'
  at src/Database/Adapter/SQL.php:638 (createDocuments)

Root cause: After merging main into feat-query-lib, skipDuplicates support was added to Adapter.php, Mongo.php, SQLite.php, Postgres.php, and Mirror.php, but the shared SQL path in src/Database/Adapter/SQL.php::createDocuments() was left calling $builder->insert() unconditionally. When Mirror::createDocuments() forwarded a batch to destination under skipDuplicates (to backfill rows that source had no-op'd via INSERT IGNORE), the destination ran a plain INSERT and threw 1062 on the pre-seeded row.

Fix: Route through the query builder's insertOrIgnore() when $this->skipDuplicates is true. The utopia-php/query builder already maps this to INSERT IGNORE INTO (MySQL/MariaDB), INSERT OR IGNORE INTO (SQLite), and INSERT … ON CONFLICT DO NOTHING (Postgres), so the single line change covers all SQL adapters.

-            $result = $builder->insert();
+            $result = $this->skipDuplicates ? $builder->insertOrIgnore() : $builder->insert();

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — critical & warning findings fixed

Ran /skills:improve on the query-library core of this PR and resolved 7 CRITICAL / 4 WARNING findings across the recent-delta files. Full diff is large (340 files), so the review was scoped to Query.php, Validator/Query/*, and Validator/Queries.php — the PR's core extraction theme.

Dimension summary

  • Security / correctness: ColumnType::from() \ValueError, Filter switch missing 10 ColumnType cases, Order::addAggregationAliases schema mutation leak across requests, aggregate-method dispatch missing half of Method::isAggregate() (stddevPop, varPop, bitAnd/Or/Xor, …), toArray() crash on non-Query child of And/Or/ElemMatch, pre-existing fatal-on-load from incompatible LOGICAL_TYPES visibility and parse() signature drift with the base library.
  • Performance / maintainability: dead duplicate-schema-lookup block in Filter::isValidAttributeAndValues, nested-recursion over Union/UnionAll that are not filter containers, drift risk between shape() and toArray() using two different literals for the same set.

Test result delta

Before this commit, the branch could not load Utopia\Database\Query (fatal: narrowed visibility on LOGICAL_TYPES; incompatible parse() signature). Fixing those plus the groupForDatabase order-attributes regression dropped unit-test failures from 31 errors + 1 failure → 2 errors + 1 failure. The 3 remaining failures are environmental (PHPUnit Swoole class, xdebug + PHP 8.2 dynamic-property on DateMalformedStringException) and are not caused by this PR.

Not addressed (intentional — out of this cycle's scope)

  • DRY refactor of the 5 near-identical Aggregate/Distinct/GroupBy/Having/Join validator skeletons.
  • Single-pass restructure of Queries::isValid (currently parses strings twice — correctness-neutral).
  • Hoisting the per-value switch-built validator in Filter::isValidAttributeAndValues out of the foreach (performance-only).
  • Cycle-guard / node-count cap on Query::shape() (theoretical DoS — unlikely to be reachable in practice).

These are worth doing but are architectural and carry regression risk for a follow-up PR.

Comment thread src/Database/Query.php Outdated
Comment thread src/Database/Query.php Outdated
Comment thread src/Database/Query.php Outdated
Comment thread src/Database/Query.php
Comment thread src/Database/Validator/Query/Filter.php Outdated
Comment thread src/Database/Validator/Query/Filter.php
Comment thread src/Database/Validator/Query/Filter.php Outdated
Comment thread src/Database/Validator/Query/Order.php
Comment thread src/Database/Validator/Queries.php Outdated
Comment thread src/Database/Validator/Queries.php Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Claude pushed fixes from: improvement

56e3342...6f2fc7f

@claude

claude Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

CI heal — follow-up (after local verification)

My first fix touched SQL::createDocuments() but locally the test still failed, surfacing two deeper layers of the same bug in the feat-query-lib merge:

  1. Database::skipDuplicates() never reached the adapter. The method set $this->skipDuplicates = true on the Database instance but Traits/Documents::createDocuments() never propagated that to $this->adapter->skipDuplicates(...). So even with insertOrIgnore() wired up in SQL.php, $this->skipDuplicates on the adapter was false at the moment of insert. Mirrors main's pattern: wrap each batch with $this->adapter->skipDuplicates($insert) when $this->skipDuplicates is set. Also declared the property on Database to avoid dynamic-property fragility.

  2. Permissions hook used plain INSERT. Even after the document INSERT IGNORE was a no-op, Hook/Permissions::afterDocumentCreate ran $permBuilder->insert() against _perms, hitting Duplicate entry 'dup-create-any' for key '_index1' when the destination already had those permissions. Piped a skipDuplicates: bool through WriteContext from SQL::buildWriteContext, and the hook now calls insertOrIgnore() when the context says so.

Local verification: MirrorTest — 653 tests, 10 761 assertions, 0 failures.

Files touched:

  • src/Database/Database.php — declare $skipDuplicates property
  • src/Database/Traits/Documents.php — propagate to adapter in createDocuments()
  • src/Database/Hook/WriteContext.php — add skipDuplicates field
  • src/Database/Adapter/SQL.php — set skipDuplicates in buildWriteContext
  • src/Database/Hook/Permissions.php — honour skipDuplicates in afterDocumentCreate

@github-actions

Copy link
Copy Markdown
Contributor

Claude pushed fixes from: healing

6f2fc7f...78529fe

@claude

claude Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

CI failure — root cause and fix

Failed run: #24887417627

What failed

Every adapter test job errored with:

Error: Undefined constant Utopia\Query\Schema\ColumnType::Serial

originating from src/Database/Validator/Query/Filter.php:174 — the per-value validator switch that the previous review-findings commit extended with ColumnType::Serial, ::BigSerial, and ::SmallSerial cases.

Why

Those three cases were added upstream in utopia-php/query commit d692372 (feat(schema): PostgreSQL SERIAL column types, 2026-04-23). Our composer.lock was still pinned to 3f57d89f (2026-03-26), which predates them. So composer install in CI resolved an older ColumnType enum without Serial, and the switch failed at runtime the first time any test exercised the Filter validator.

Fix

Ran composer update utopia-php/query to roll the pin forward to the current tip of feat-builder (5ca0401), which includes the SERIAL cases. Only composer.lock is changed — no code changes needed. Verified locally that the enum loads all three cases and Filter.php is reachable via autoload.

@github-actions

Copy link
Copy Markdown
Contributor

Claude pushed fixes from: healing

78529fe...b44c8cd

abnegate and others added 3 commits September 30, 2026 01:47
Every getDocument() inside a transaction skipped the document cache, collection
definitions included, so each write re-read its own collection definition from
_metadata inside its transaction: +1 statement per update, increase, decrease and
delete, +3 in withTransaction(update + get), and +5 to +45 per nested
relationship create (B-03, J3C-003/J3C-014). 7.3.12 served hits there.

A read inside a transaction now serves a cached copy when the invalidation scope
of its coroutine started the transaction and has not written that document.
Single-document writes record their lower-cased document key in the scope
(transactionWrites), so a document the transaction wrote, under any casing an
adapter may match, is read from the adapter: another reader can refill its slot
with the committed row after the purge inside the transaction. Batch writes and
schema changes keep the collection's epoch blocked, which already makes those
reads miss. A transaction the database did not start (one begun on the adapter,
or a Pool transaction another coroutine owns) keeps reading uncached, since its
earlier writes may sit in scopes that have already closed.

Reads inside a transaction still never fill: a transaction reads its snapshot,
which under REPEATABLE READ can predate another writer's commit and post-commit
purge, and a lease taken after that purge would accept the stale row.

Collection definitions no longer split their cache field by whether the
relationship hook is enabled: a definition has no relationship attributes, so
the hook never changes what is read. Without this, every lookup made under
skipRelationships() inside a nested create missed a field that only a read
outside a transaction fills.

The cost moves from the database to the cache: the locking read's collection
lookup is now a cache hit (6 round trips until one round trip per lookup), so
the single-document writes that read first take 14 round trips on a warm cache
(8 before), pinned in DocumentCacheInvalidationTest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A getDocument() miss re-read the row with authorization skipped before
publishing the shared negative entry, in case the adapter had filtered it out
by the caller's permissions. Collection definitions paid that re-read too, so
getCollection('missing') and createCollection()'s free-id check cost two
_metadata reads where 7.3.12 paid one (H-07/Q10, JDB13-015; J3C-025, the two
extra _metadata reads of schema.create_collection_with_10_attributes).

No adapter filters definitions by permission: every write resolves its
collection through them, so a filtered definition would already break writes
for callers without read access. The re-read stays for every other collection,
where MongoFilteredMissCacheTest still relies on it.

The other half of J3C-025, the cached miss retired by an unrelated _metadata
write, went with the per-document purges of j-db-fix-04;
testCreateCollectionAfterAProbeReadsNoDefinition pins it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ReadWritePool sends reads outside the sticky window to a replica, and
getDocument() and find() cached whatever came back. A lagging replica's row,
its "not found", or its query result then went into the shared document and
query caches under the current epoch, so every handle kept reading the old
version until the next write or the 24 h TTL (H-02, J4L-006).

ReadWritePool now records, per coroutine, whether the latest data read was
served by the replica pool, and Database skips the document save, the negative
marker and the query-cache save for such a read. The record lives in the
coroutine's context (an instance field outside coroutines), so a read on one
coroutine cannot hide or fake where another coroutine's read was served, and it
is freed with the coroutine. Reads the pool sends to the primary (inside a
transaction, in the sticky window, or H-01's write-deciding reads) fill as
before, so the caches are still filled right after writes; a replica-served
read is simply read again from the replica next time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Comment on lines +2071 to +2073
$collect = function (Document $updated) use (&$cleared): void {
$cleared[] = $updated;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Unused Reports Retain Documents

For a SetNull delete with many related documents, this callback keeps every updated document across all chunks, even when there are no lifecycle listeners and the report will be discarded. This removes the memory bound provided by chunking and can increase a worker’s memory use substantially. Collect the documents only when a report is needed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Database/Hook/Relationships.php
Line: 2071-2073

Comment:
**Unused Reports Retain Documents**

For a SetNull delete with many related documents, this callback keeps every updated document across all chunks, even when there are no lifecycle listeners and the report will be discarded. This removes the memory bound provided by chunking and can increase a worker’s memory use substantially. Collect the documents only when a report is needed.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

abnegate and others added 8 commits September 30, 2026 01:53
…e per coroutine

Relationship population reads chunks of related ids in Promise::map() child
coroutines. The children shared the handle's Authorization status and the
relationship hook's flags, and find(), skip() and skipRelationships() saved,
changed and restored that shared state, so siblings restored each other's
values in completion order. One find() could leave the handle with
authorization and relationships disabled, after which reads returned
documents that only document-level grants protect. silent() was keyed per
coroutine id, so the children also escaped the caller's silence (B-11).

Scoped toggles now open an override that belongs to the calling coroutine and
the coroutines it starts (State\Value): Authorization::skip()/withStatus(),
find()'s authorization skip (now the scoped form), skipRelationships(),
skipRelationshipsExistCheck(), the batch-population flag and silent(). A read
takes the innermost override of the current coroutine, else the nearest
ancestor's, else the handle-wide value. Unscoped writes (setStatus, enable,
disable, reset, setEnabled, setCheckExist) change the current coroutine's
innermost override when there is one and the handle-wide value otherwise, so
a disable() made in a worker or migration coroutine is still seen by the
coroutines it starts and by later work, as appwrite relies on.

Chunks are read concurrently only inside a coroutine, on Adapter\Pool (one
borrowed connection per read) and outside a transaction; otherwise they are
read one after another in the caller. The Pool keeps its transaction pin per
handle. Each concurrent read starts from Database::snapshot() of its caller
through withSnapshot(), which Mirror can reuse to run replication under the
caller's state. Chunk results are merged in chunk order.

Gaps: J4C-006 (alias J4A-007), B-11.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
utopia-php/query 0.6.1 compiles contains/containsAny/containsAll/notContains
on SQLite array attributes as `json_each.value = json(?)` with the JSON
encoding bound. json() returns JSON text (`"a"`, `1`, `true`) while
json_each.value holds the element's SQL value (a, INTEGER 1, 1), so no
string, integer, double or boolean element ever matched: containsAny and
containsAll returned no rows and notContains returned every row
(J4C-009 = J4Q-001, critical).

Compare with json_extract(?, '$'), which turns the bound JSON into the same
SQL value json_each yields (verified for strings, non-ASCII, quotes,
integers, doubles and booleans in the j-database-09 probes). The override
sits on compileJsonContainsExpr()/compileJsonOverlapsExpr(), so the
query-builder JSON filters (jsonContains/jsonNotContains/jsonOverlaps) are
fixed as well, not only the array filter.

notContains and jsonNotContains now also require the column to be non-NULL:
MariaDB/MySQL (NOT JSON_OVERLAPS/JSON_CONTAINS), PostgreSQL (NOT @>),
Memory and Redis all exclude a document whose array is NULL, while
`NOT EXISTS (... json_each(NULL))` kept it on SQLite (user decision W1-11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
notContains on an array attribute compiled to `{attr: {$nin: [...]}}`, and
MongoDB's $nin also matches documents where the field is missing or null.
MariaDB, MySQL, PostgreSQL, Memory, Redis and (now) SQLite exclude those
documents, so the same query returned extra rows on MongoDB only. Adding
`$ne: null` keeps empty arrays and drops missing/null ones, matching the
other engines (user decision W1-11: notContains excludes NULL arrays on
every engine).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
SQLite dropped Capability::QueryContains, so testFindContains,
testFindNotContains and the contains block of testArrayAttribute skipped on
both SQLite lanes and nothing caught array contains returning wrong rows
there (J3-001). With element comparison fixed, all three gated tests pass
on SQLite, plain and shared tables, so the capability is declared again.

testArrayContainsQueriesOnScalarArrays pins one data set (strings with
non-ASCII and quote characters, a numeric string, integers, doubles,
booleans, an empty array and a NULL array) and the same expected rows for
containsAny, containsAll, notContains and the deprecated contains form on
every adapter with defined attributes, so all lanes have to agree,
including on notContains leaving out the NULL array.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
find() results were stored under one key per epoch and query
(`<scope>#<epoch>:<query>`). Every invalidation published a new epoch and the
old keys stayed in Redis with no expiry, so the key count grew by one per
invalidate-and-fill cycle (J4C-004, query half of B-05-R).

Results now live in one hash per collection scope (the scope key itself), one
field per query. The value records the epoch it was filled under and its own
field; a hit needs both to match, so a fill that raced an invalidation is never
served under the next epoch, and caches that ignore fields (Memory,
Filesystem) never serve one query another query's rows (W1-23): there a scope
holds one result at a time. Blocking a collection purges the hash, so a
rotation clears the previous epoch's fields and, on leasable caches, also
bumps the lease generation for the whole scope (W1-20: accepted; the lease and
tombstone are collection-wide and a collection's results share one key/slot).

Round trips are unchanged: a hit is 3, a miss 5. The Greptile r4120070321
thread asked for no exact key set: the rewritten test pins the key and field
counts after cycle 20 to cycle 1's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…'s transaction

deleteDocument() read the document inside the withMutation() closure and
leaked it out through a by-reference capture, so after the transaction
PHPStan only knew it as ?Document. The old triggerHooks() took mixed and hid
that; the typed triggerDeleteHooks() added for the peer updates does not.
The closure now returns the document it deleted, or null when there was
nothing to delete or the adapter deleted nothing, so the hooks receive a
Document by construction and the by-reference capture is gone.

The e2e test read hook payloads as list<mixed> and passed them to closures
typed on Document. EventRecorder::getDocuments() returns the payloads of an
event as list<Document> and throws on anything else, so a wrong payload fails
the test loudly instead of being cast, and the id closure takes Document
variadics so its input is typed natively.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…eries() reports cache failures

purgeCachedCollection() only advanced the document-cache epoch, so a
collection's find() results stayed cached after it: a write made around the
query cache (a migration, another process without it) stayed invisible until
the next write through the library (H-08). It now also invalidates the
collection's query cache, as purgeCachedQueries() does; a failure propagates
like the document half's, and the callers retry it.

purgeCachedQueries() loaded the withCache() region epoch outside its try, so a
cache failure threw although UPGRADE documents a `false` return. The load,
purge and save of that epoch now log a warning and make it return false.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
With a query cache installed, a cache backend error anywhere in find()'s
query-cache block (the epoch load, the generation reads, the entry load, the
fill) made find() throw, while getDocument() logs a warning and reads the
database (H-09). find() now does the same: a failure while resolving or
loading an entry reads uncached and fills nothing; a failure while filling
keeps the rows it read. Building the cache key stays outside the catch, so an
invalid query still throws.

QueryCache::getEpoch() threw on an epoch value it did not write; it is now a
miss for that read (uncached, no fill), and the next invalidation overwrites it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
$this->assertSame(['value' => 'first'], $first);
$this->assertSame(['value' => 'second'], $second);
$this->assertSame(['value' => 'first'], $cachedFirst);
$this->assertSame(1, $firstCalls);
$this->assertSame(1, $secondCalls);
$this->assertSame(['first-field', 'second-field'], $cache->list('key'));
$this->assertSame(3, $cache->getSize());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Test Pins Cache Key Count

This assertion requires the cache to use exactly three storage keys for two hash fields. A change to the cache layout could preserve the correct results but fail the test. The assertions above already verify both cached values and that neither callback runs again. The repository requires tests to check observable behavior rather than internal storage details; this requirement must be satisfied before merging.

Suggested change
$this->assertSame(3, $cache->getSize());

Context Used: Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/QueryCacheTest.php
Line: 205

Comment:
**Test Pins Cache Key Count**

This assertion requires the cache to use exactly three storage keys for two hash fields. A change to the cache layout could preserve the correct results but fail the test. The assertions above already verify both cached values and that neither callback runs again. The repository requires tests to check observable behavior rather than internal storage details; this requirement must be satisfied before merging.

```suggestion

```

**Context Used:** Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

abnegate and others added 16 commits September 30, 2026 02:12
A writer killed between its tombstone and its activation left #started ahead
of #finished for good. The tombstone only lapsed with the region TTL once the
scope was quiescent, so the collection's query cache stayed off until a flush,
and every later write left its own tombstone behind because its activation saw
the dead writer still counted in flight (PN-db03-2, the query-cache twin of
B-01).

Bound: a new writer timeout (QueryCache constructor, default 3600 s).
- Read side: a tombstone older than the writer timeout lapses even while
  writes are counted in flight (older than the region TTL once none are, as
  before). The lapsed epoch is derived from the tombstone and the finished
  generation instead of the constant initial epoch, so a pre-block fill that
  raced the block is never re-exposed, and any later activation retires what
  readers filled during the lapse.
- Reconciliation: tokens now record their creation time. An activation that
  finishes its own write while others are still counted lists the scope's
  owner registrations; when every other one is older than the writer timeout
  it releases them and publishes a fresh epoch stamped with the current
  started generation. A token without a time counts as live, and caches that
  keep no fields (no listing) skip this and rely on the read side.
- A writer whose registration was released that way still publishes a fresh
  epoch when it activates, so what readers filled during its transaction is
  retired.

A live transaction younger than the writer timeout keeps its block, as
before. One older than it is treated as abandoned: readers may fill from the
pre-commit state while it runs, and its activation retires those fills, so
only the window between its commit and its activation can serve them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ion was lost

When the server ended the session inside an outer withTransaction(), the
nested call's SAVEPOINT reached the dead connection, the PDO wrapper
reconnected and rethrew, and rolling back on the new connection failed.
withTransaction() then zeroed the counter and retried, so the nested
call began a fresh top-level transaction and committed its work alone;
the outer commit saw no transaction and returned false, and the outer
call returned normally with its own writes lost.

withTransaction() now records the depth it was entered at. A nested
call whose rollback leaves the counter below that depth, or a call whose
own level vanished underneath it, throws a TransactionException instead
of retrying, so no call begins a fresh top-level transaction inside what
its caller still treats as an open one, and the failure reaches the
outer call. The check runs before the non-retryable exception list, so a
duplicate the caller expects to catch cannot mask the lost transaction.
Nested retries while the enclosing transaction holds, and the top-level
reset-and-retry after a failed rollback (#898), keep
their behaviour.

SQL::commitTransaction() throws when a nested commit finds the driver no
longer holds the transaction, instead of resetting the counter and
returning false. The top-level case still returns false: #898's
testWithTransactionResetsCounterOnRollbackFailureDuringRetry stubs
inTransaction() as false for a transaction it expects to commit.

Closes J4L-001 (ledger 14.P1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
UPGRADE.md promises document_purge once the write's transaction has
committed, but a write inside a caller's withTransaction() fired it inside
that transaction, before the commit, once per retried attempt, and even when
the caller's transaction later rolled back. A listener that purges its own
cache could then refill it from the uncommitted state or purge for a write
that never happened.

Writes now queue the event in the invalidation scope from inside their
mutation. The outermost scope fires the queue after its commit and the
post-commit invalidation; a rollback drops it, every retried attempt starts
from the events queued before its transaction, and a nested transaction that
fails drops the events queued inside it. A queued event keeps the tenant and
the hook silences in force when the document was written. A write the adapter
holds no transaction for (MongoDB without a replica set) is already durable
and still announces at once. Top-level writes fire once after their commit,
as before.

Closes JDB13-019 (H-14).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ion fails

A cache failure in the invalidation after a commit made updateDocument(),
deleteDocument() and the other writes throw with the data committed, and
document_purge never fired: the triggers ran only after withMutation()
returned. Listeners that keep their own caches were left serving the
pre-write state until their TTL.

The outermost scope now fires the queued document_purge events after the
post-commit invalidation whether or not it succeeded, and then rethrows the
invalidation failure, which still reaches the caller first (the throw stays,
as DocumentCacheEpochTest::testActivationPropagatesAnOwnerReleaseFailure
pins). Every queued event is delivered even if a listener throws; the first
failure is rethrown after the last event.

Closes JDB13-014 (H-06).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tion

Every cache lookup read #started, #finished, #epoch and both generations
again before the entry: 6 round trips per lookup, 12 for a getDocument()
hit whose collection lookup paid the same (bench-core B-04 read path,
J3C-001; the reads 1.2-4x slower than 7.3.12, J3C-002).

After j-db-fix-04 a definition is invalidated per document, so its cache
entry can carry the epoch its collection's documents are cached under:
getCollection() is one round trip and getDocument() two, as in 7.3.12.
Batch writes and schema changes that block or publish an epoch purge the
definition after changing the record, and a definition fill takes its
lease before reading the record, so no fill outlives a newer epoch; on a
cache without generations the fill reads the record again and drops what
it saved when the record moved. A definition holding a blocked collection
is looked at again after a minute.

The epoch record now tells readers the state on its own: an activation
publishes only when no other write is in flight (the last one publishes),
and a published epoch carries the started generation it was published at.
A global collection's definition is one key for every tenant, so each
tenant keeps its field (its own epoch). Inside a transaction that retired
a collection, its documents are read uncached without an epoch read.

`_metadata` is not checked against an epoch any more: batch writes to it
purge each definition they write, purgeCachedCollection('_metadata')
purges every listed definition, and a failed post-commit purge of a
definition is retried once (a definition has no epoch to retire).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The write paths paid the same 6-round-trip collection lookups
(J3C-005: update.single 36 → 14, withTransaction(update + get) 26 in the
unit model; J3C-007: a sibling read after a write 12). With one round trip
per lookup they are back at or below database 7.3.12's counts
(bench-core, MariaDB): create 3, update/increase/decrease/delete 4 (7.3.12:
6/4/4/6), withTransaction(update + get of a sibling) 6 (11), ten
update + get pairs 80 (110), a sibling read 2 with no adapter read.

The unit guards pin the reached figures with assertSame so a regression
fails with 7.3.12's figure in the message. The e2e block bounds the same
operations on every lane with a document cache, through a counting cache
over Redis and the profiler; statements are bounded only on the MariaDB
and MySQL lanes, the engines whose 7.3.12 counts were measured, and
Mirror is skipped since its destination writes share the counted cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…r timeout

A write killed between its block and its activation (or whose activation
failed, or whose counter was evicted) left #started ahead of #finished for
good, and every later write preserved the gap: the collection's document
cache stayed off until a flush (B-01, J4L-002; at the start point five
reads after three more batch writes and purgeCachedCollection() still
reached the database five times).

This uses j-db-fix-05's rule for the query cache so both caches behave
alike. Tokens record their creation time and a tombstone its block time.
A tombstone older than the writer timeout (setCacheWriterTimeout(),
default 3600 s, 05's writerTimeout) lapses for readers into an epoch of
its own, derived from the tombstone and #finished, so nothing filled
before the block is served and any later activation retires what readers
filled meanwhile. The next write reconciles: when every other registered
write is older than the timeout it releases them and publishes an epoch
stamped with the current #started. A write whose registration was released
still publishes over an active epoch when it activates, and a missing or
unusable record with counters out of step becomes a stamped tombstone so
it lapses too. A write younger than the timeout keeps the collection
blocked exactly as before; one older is treated as abandoned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ery-lib

Stage A of the merge-readiness fixes. Parallel relationship population
shared authorization, relationship and silence state across coroutines
(J4C-006); the branch started at 30f25fd, before the 7.4.0
forward-port, so it lands by merge.

RelationshipTests.php: 7.4.0 and this branch each appended tests at the
end of the trait; both blocks are kept, with this branch's Swoole
imports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ry-lib

Stage A of the merge-readiness fixes. SQLite contains queries compared
JSON text with SQL values and matched no element (J4C-009/J4Q-001), and
notContains included NULL arrays on SQLite and MongoDB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…uery-lib

Stage A of the merge-readiness fixes. A nested withTransaction() after a
lost connection began a fresh top-level transaction and committed its
writes alone while the outer call returned normally (J4L-001).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…y-lib

Stage A of the merge-readiness fixes, first link of the cache chain
(04 -> 07 -> 09). One cache slot per document, and single-document
writes invalidate only their document instead of blocking the whole
collection (J3C-004, J3C-006, J3C-008, J4C-004).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…uery-lib

Stage A of the merge-readiness fixes, second link of the cache chain.
Reads inside a transaction use the cache for documents the transaction
has not written (J3C-003), a missing collection costs one _metadata read
(JDB13-015), and replica-served reads fill no cache (J4L-006).

GeneralTests.php: j-db-fix-03 and this branch each appended a test at
the end of the trait; both are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Stage A of the merge-readiness fixes, last link of the cache chain. A
collection definition carries its document-cache epoch, so lookups cost
one cache round trip again (J3C-001, J3C-002), write and transaction
round trips are pinned at 7.3.12's (J3C-005, J3C-007), and an abandoned
write lapses after the writer timeout (J4L-002).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Stage A of the merge-readiness fixes. One query-cache hash per
collection scope (J4C-004 query half), purgeCachedCollection() reaches
cached finds (JDB13-016), a query-cache failure falls back to the
database (JDB13-017), and a killed writer's tombstone lapses after the
writer timeout (JDB13-043).

Documents.php conflicts, resolved so every branch's invariant holds:
- purgeCachedCollection(): j-db-fix-09 returns early for _metadata after
  purging the definitions and passes the definition key to
  advanceDocumentCacheEpoch(); this branch's query-cache invalidation
  now runs in both branches, so purgeCachedCollection('_metadata') still
  drops cached finds.
- find(): the query-cache fill keeps this branch's fallback try, with
  j-db-fix-07's replica guard inside it, so a replica-served result is
  never cached and a cache error still only logs a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…query-lib

Stage A of the merge-readiness fixes. document_purge fires after the
outermost commit and never after a rollback (JDB13-019), and still fires
when the post-commit invalidation fails (JDB13-014).

Conflicts, resolved so every branch's invariant holds:
- Transactions.php: j-db-fix-07 (transactionWrites) and this branch
  (documentPurgeEvents) each add a property, one line in the outer-scope
  open and one entry in both unset() lists; all are kept.
- Documents.php deleteDocument(): 7.4.0 returns the deleted document out
  of the withMutation() callback and fires the delete and related-update
  hooks after it; this branch queues document_purge inside the callback.
  The callback now queues the purge when the adapter deleted the row and
  returns the document, and the old post-callback trigger is dropped, so
  the purge still fires after the commit and before document_delete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
j-db-fix-01 replaced Database's per-coroutine $silencedListeners array
with a State\Value, and j-db-fix-08's queueDocumentPurge() still indexed
the property as an array, so every write inside a transaction failed
with "Cannot use object of type Utopia\Database\State\Value as array"
once both were merged (DocumentPurgeTest: 36 errors, 23 failures). The
queued event now captures the silences the writing coroutine sees via
silencedListeners()->get(), which is what 08 meant to record.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
$this->assertSame('round '.$round, $database->getDocument('webhooks', 'hook')->getAttribute('name'));
}

$this->assertSame(80, $cache->getOperations(), 'Ten updateDocument() + getDocument() pairs (cache.keys_after_1000_writes; 7.3.12: 110 round trips)');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Tests Pin Cache Operations

This test requires exactly 80 cache operations for ten update/read pairs. An equivalent cache implementation could add a lease check and fail this assertion while returning the same documents within the stated performance bound. The repository requires tests to check observable behavior, not mirror implementation details or pin version-specific counts. This requirement must be satisfied before merging. The same pattern appears in DocumentCacheInvalidationTest.php’s exact operation counts and DocumentCacheEpochTest.php’s literal blocked: assertions.

Context Used: Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/Documents/DocumentCacheRoundTripTest.php
Line: 217

Comment:
**Tests Pin Cache Operations**

This test requires exactly 80 cache operations for ten update/read pairs. An equivalent cache implementation could add a lease check and fail this assertion while returning the same documents within the stated performance bound. The repository requires tests to check observable behavior, not mirror implementation details or pin version-specific counts. This requirement must be satisfied before merging. The same pattern appears in `DocumentCacheInvalidationTest.php`’s exact operation counts and `DocumentCacheEpochTest.php`’s literal `blocked:` assertions.

**Context Used:** Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

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